feat(models): 组织、用户、角色、客户、会话与消息模型

按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
WangShen007 committed 2026-09-23 22:05:00 +08:00
1 parent 977783c126
commit 9eb8613c05
126 files changed
+17436

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
# Runtime guard for the pinned agno==1.7.6 (GHSA-77rh-m34w-rv36, fixed
# upstream in 2.3.24). Remove this module with the agno 2.x upgrade.
#
# agno turns a user-input field's type name back into a Python type by calling
# eval() on a string it did not produce: in agno.models.base the string is the
# `field_type` of a `get_user_input` tool call, i.e. text the model wrote, and
# in agno.tools.function it is read back from a stored run response. A tool
# named get_user_input is all it takes to reach that eval, and MCP connectors
# register tools under whatever name the server advertises, so a hostile or
# prompt-injected connector could run arbitrary Python inside the backend.
#
# Both modules look `eval` up as a global name, so binding a module-level `eval`
# shadows the builtin for those two modules only. The replacement never
# evaluates anything: it maps a bare builtin type name to the type and refuses
# everything else.
import agno.models.base
import agno.tools.function
from app.core.logger import get_logger
logger = get_logger(__name__)
# Every type UserInputField.to_dict can emit (it writes type.__name__). Nothing
# else was ever a valid field type, so refusing it changes no legitimate path.
_FIELD_TYPES = {t.__name__: t for t in (str, int, float, bool, list, dict, tuple, set, bytes)}
_GUARDED_MODULES = (agno.models.base, agno.tools.function)
class UnsafeFieldTypeError(NameError):
"""Raised for any field_type that is not a plain builtin type name.
A NameError on purpose: agno's model loop catches (NameError, SyntaxError)
around its eval and falls back to str, so a rejected value degrades exactly
the way an unknown name always did — the run continues, nothing executes.
"""
def resolve_field_type(expr, *_args, **_kwargs):
"""Drop-in for eval() that only ever maps a type name to the type.
Accepts eval's optional globals/locals so any call shape agno uses works.
"""
if isinstance(expr, str):
field_type = _FIELD_TYPES.get(expr.strip())
if field_type is not None:
return field_type
# agno swallows the error and carries on with str, so without this line a
# blocked payload would leave no trace at all. Nothing legitimate reaches
# here, which makes every one of these worth seeing.
logger.warning(f"Blocked an unsafe user-input field type: {expr!r}")
raise UnsafeFieldTypeError(f"Unsupported user-input field type: {expr!r}")
def install_eval_guard() -> None:
"""Shadow eval in the agno modules that hand model-supplied text to it."""
for module in _GUARDED_MODULES:
module.eval = resolve_field_type
+172
View File
@@ -0,0 +1,172 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
# Runtime patches for the pinned agno==1.7.6. Remove this module with the
# agno 2.x upgrade.
#
# agno 1.7.6 does not terminate a run when tool_call_limit is exceeded: it
# appends a tool-result message asking the model not to call the tool again
# and sends the conversation back to the model. Models that ignore the
# instruction (observed with gpt-4o-mini) then loop forever, re-sending an
# ever-growing payload on every iteration (issue #269).
#
# The first fix set stop_after_tool_call=True on the limit-error message, which
# breaks the response loop in agno.models.base. That ended the loop, but it ended
# it AT THE TOOL RESULT — before the model ever wrote an answer — so every run
# that reached the limit returned an empty turn and the visitor got the
# "I didn't quite catch that" fallback. In production that was most turns on any
# agent with a few knowledge sources.
#
# What we actually want is a bounded run that still answers: at the limit, stop
# offering tools and let the model reply from what it already retrieved. Clearing
# `tools` for the next completion does exactly that, and it also makes the runaway
# loop impossible — a model with no tools cannot call one.
#
# There is deliberately NO hard-stop backstop. An earlier version kept one for the
# case where withdrawal somehow failed, and it turned out to be the only remaining
# producer of empty replies: it fired occasionally and cost the visitor an answer
# every time. The hang it guarded against is already prevented by the
# AGENT_RUN_TIMEOUT wrapper around every agno run, which does not depend on this
# module patching the right entry points. Two locks on one door, and this was the
# lock that slammed on the user.
from agno.models.base import Model
from app.core.logger import get_logger
from app.utils.agno_eval_guard import install_eval_guard
logger = get_logger(__name__)
_PATCHED_FLAG = "_replypier_answers_at_limit"
# Set on the Model instance once this run has spent its tool budget. Safe as
# instance state: create_model() builds a fresh Model per ChatAgent and ChatAgent
# is built per request, so it never outlives a single run.
#
# A model can request SEVERAL tools in one assistant message and agno walks that
# whole batch before calling the model again, so this is hit repeatedly within a
# single round. That is fine — setting it twice is a no-op. It mattered only while
# there was a backstop that had to tell a parallel sibling apart from a genuine
# repeat; without one, a plain flag is enough.
_EXHAUSTED = "_replypier_tools_exhausted"
# Groq's structured output arrives as a tool call of this name rather than as
# text. Kept in step with app.agents.structured_output.build_groq_json_tool.
CAPTURE_TOOL_NAME = "json"
# Replaces agno's own limit-error text, which only tells the model it may not
# call the tool again. Left at that, gpt-4o-mini narrated the plumbing to the
# visitor ("I wasn't able to retrieve ... due to tool limitation") and then
# answered from general knowledge — which also contradicts the grounding rule the
# guardrail relies on. Say what to do instead: answer from what is already here,
# and if that is not enough, admit it rather than invent.
_LIMIT_INSTRUCTION = (
"You have used all the tool calls available for this turn. Answer now, using "
"only what the tools have already returned and what your instructions cover. "
"Do not mention tools, searches, limits or any internal constraint — the "
"visitor must not read about the plumbing. If what you have is not enough to "
"answer properly, say plainly that you don't have that detail and offer to "
"connect them with the team. Never fill the gap with general knowledge."
)
def _tool_name(tool) -> str:
"""Name out of a provider-format tool definition, whatever the nesting."""
if not isinstance(tool, dict):
return ""
fn = tool.get("function")
if isinstance(fn, dict):
return fn.get("name") or ""
return tool.get("name") or ""
def _strip_tools(self, kwargs: dict) -> dict:
"""Withdraw the action tools once the budget is spent — but never the tool
the model answers THROUGH.
On Groq the structured response is not text: it is a tool call named `json`
whose arguments are the response fields (see app.agents.structured_output).
Withdrawing every tool would take away the only way that model has to reply,
so a Groq agent hitting the limit would return an empty turn every time —
precisely the failure this patch exists to remove.
"""
tools = kwargs.get("tools")
if not getattr(self, _EXHAUSTED, False) or not tools:
return kwargs
kept = [t for t in tools if _tool_name(t) == CAPTURE_TOOL_NAME]
if kept:
# Leave tool_choice alone: the capture tool is how the answer is emitted,
# so the model must still be allowed — or required — to call it.
return {**kwargs, "tools": kept}
return {**kwargs, "tools": None, "tool_choice": None}
def apply_agno_patches() -> None:
"""Apply all agno runtime patches. Safe to call more than once."""
# Idempotent on its own, so it sits before the flag check: the guard must be
# in place whichever call reaches here first.
install_eval_guard()
if getattr(Model.create_tool_call_limit_error_result, _PATCHED_FLAG, False):
return
original_limit_result = Model.create_tool_call_limit_error_result
original_process = Model._process_model_response
original_aprocess = Model._aprocess_model_response
# Streaming is a separate pair of entry points that never touch the two
# above. Patching only the non-streaming ones left the live chat — which
# streams — with its tools intact after the limit, so the next round called a
# tool again and hit the backstop. Verified against a real agent.
original_stream = Model.process_response_stream
original_astream = Model.aprocess_response_stream
def create_tool_call_limit_error_result(self, function_call):
message = original_limit_result(self, function_call)
# Every over-budget call in the batch gets the instruction, not just the
# first: the model reads all of the tool results, and leaving agno's
# "tool call limit reached" text on the siblings was enough for it to keep
# narrating the limit to the visitor.
message.content = _LIMIT_INSTRUCTION
if not getattr(self, _EXHAUSTED, False):
setattr(self, _EXHAUSTED, True)
logger.warning(
"Tool call limit reached; answering without further tools "
f"(tool={function_call.function.name})"
)
return message
def _process_model_response(self, *args, **kwargs):
return original_process(self, *args, **_strip_tools(self, kwargs))
async def _aprocess_model_response(self, *args, **kwargs):
return await original_aprocess(self, *args, **_strip_tools(self, kwargs))
def process_response_stream(self, *args, **kwargs):
yield from original_stream(self, *args, **_strip_tools(self, kwargs))
async def aprocess_response_stream(self, *args, **kwargs):
async for chunk in original_astream(self, *args, **_strip_tools(self, kwargs)):
yield chunk
setattr(create_tool_call_limit_error_result, _PATCHED_FLAG, True)
Model.create_tool_call_limit_error_result = create_tool_call_limit_error_result
Model._process_model_response = _process_model_response
Model._aprocess_model_response = _aprocess_model_response
Model.process_response_stream = process_response_stream
Model.aprocess_response_stream = aprocess_response_stream
+208
View File
@@ -0,0 +1,208 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import re
from agno.agent import Agent
from agno.models.openai import OpenAIChat
from openai import AsyncOpenAI, OpenAI
from app.core.logger import get_logger
from app.core.config import settings
from app.core.outbound_http import guarded_async_http_client, guarded_http_client
from app.core.outbound_policy import check_outbound_target, public_only
from app.utils.agno_patches import apply_agno_patches
from typing import Dict, Any, Optional, List
from fastapi import HTTPException
logger = get_logger(__name__)
# Every code path that builds an agno model goes through this module, so this
# is the single choke point where the 1.7.6 runtime patches get applied.
apply_agno_patches()
class PinnedOpenAIChat(OpenAIChat):
"""OpenAIChat for a user-supplied base URL under OUTBOUND_URL_POLICY=
public_only: every connection, redirect included, goes to a vetted public
address. agno 1.7.6 takes a single http_client for both its sync and
async OpenAI clients, so each getter supplies its own guarded one, fresh
per call like agno's default."""
def get_client(self):
# follow_redirects matches the OpenAI SDK's own default client.
return OpenAI(**{**self._get_client_params(),
"http_client": guarded_http_client(follow_redirects=True)})
def get_async_client(self):
return AsyncOpenAI(**{**self._get_client_params(),
"http_client": guarded_async_http_client(follow_redirects=True)})
def create_model(model_type: str, api_key: str, model_name: str, max_tokens: int = 1000, response_format: Optional[Dict[str, Any]] = None, base_url: Optional[str] = None) -> Any:
"""
Create and return the specified model based on model_type.
Args:
model_type: The type of model (OPENAI, GROQ, etc.)
api_key: The API key
model_name: The name/ID of the model
max_tokens: Maximum tokens for model output
response_format: Optional response format specification
base_url: Custom API endpoint, used by OPENAI_COMPATIBLE
Returns:
The initialized model object
Raises:
HTTPException: If the model type is not supported
"""
model_type = model_type.upper()
try:
if model_type == 'OPENAI' or model_type == 'REPLYPIER': # own model for enterprise customers
if response_format:
return OpenAIChat(api_key=api_key, id=model_name, max_tokens=max_tokens, response_format=response_format)
else:
return OpenAIChat(api_key=api_key, id=model_name, max_tokens=max_tokens)
elif model_type == 'OPENAI_COMPATIBLE':
if not base_url:
raise ValueError("base_url is required for OPENAI_COMPATIBLE")
# Re-checked on every build, not only on save: DNS can change.
check_outbound_target(base_url, "Base URL")
base_url = re.sub(r'/chat/completions/?$', '', base_url.strip(), flags=re.IGNORECASE).rstrip('/')
model_cls = PinnedOpenAIChat if public_only() else OpenAIChat
if response_format:
return model_cls(api_key=api_key, id=model_name, base_url=base_url, max_tokens=max_tokens, response_format=response_format)
else:
return model_cls(api_key=api_key, id=model_name, base_url=base_url, max_tokens=max_tokens)
elif model_type == 'ANTHROPIC':
from agno.models.anthropic import Claude
return Claude(api_key=api_key, id=model_name, max_tokens=max_tokens)
elif model_type == 'DEEPSEEK':
from agno.models.deepseek import DeepSeek
return DeepSeek(api_key=api_key, id=model_name, max_tokens=max_tokens)
elif model_type == 'GOOGLE':
from agno.models.google import Gemini
# Gemini's agno model exposes max_output_tokens, not max_tokens.
return Gemini(api_key=api_key, id=model_name, max_output_tokens=max_tokens)
elif model_type == 'GOOGLEVERTEX':
from agno.models.vertexai import Gemini
return Gemini(api_key=api_key, id=model_name, max_tokens=max_tokens)
elif model_type == 'GROQ':
from agno.models.groq import Groq
# Create a patched Groq model that handles the response_format + tools conflict
class PatchedGroq(Groq):
def __post_init__(self):
"""Set supports_native_structured_outputs after initialization"""
super().__post_init__() if hasattr(super(), '__post_init__') else None
# Override to indicate Groq supports structured outputs via response_format
object.__setattr__(self, 'supports_native_structured_outputs', True)
def get_request_params(self, response_format=None, tools=None, tool_choice=None):
"""
Override get_request_params to handle the conflict between response_format and tools.
Groq API doesn't allow JSON response_format when tools are present.
"""
# Get the base parameters from parent class
base_params = {
"frequency_penalty": self.frequency_penalty,
"logit_bias": self.logit_bias,
"logprobs": self.logprobs,
"max_tokens": self.max_tokens,
"presence_penalty": self.presence_penalty,
"seed": self.seed,
"stop": self.stop,
"temperature": self.temperature,
"top_logprobs": self.top_logprobs,
"top_p": self.top_p,
"user": self.user,
"extra_headers": self.extra_headers,
"extra_query": self.extra_query,
}
# Filter out None values
request_params = {k: v for k, v in base_params.items() if v is not None}
# Add tools first
if tools is not None:
request_params["tools"] = tools
if tool_choice is not None:
request_params["tool_choice"] = tool_choice
# Don't add response_format when tools are present to avoid conflict
logger.debug("Groq: Skipping response_format due to tools being present")
else:
# Only add response_format if no tools are present
if response_format is not None:
request_params["response_format"] = response_format
# Add additional request params if provided
if self.request_params:
request_params.update(self.request_params)
return request_params
# Groq model doesn't support response_format parameter in constructor
return PatchedGroq(api_key=api_key, id=model_name, max_tokens=max_tokens)
elif model_type == 'MISTRAL':
from agno.models.mistral import MistralChat
return MistralChat(api_key=api_key, id=model_name, max_tokens=max_tokens)
elif model_type == 'HUGGINGFACE':
from agno.models.huggingface import HuggingFace
return HuggingFace(api_key=api_key, id=model_name, max_tokens=max_tokens)
elif model_type == 'OLLAMA':
from agno.models.ollama import Ollama
return Ollama(id=model_name)
elif model_type == 'XAI':
from agno.models.xai import xAI
return xAI(api_key=api_key, id=model_name, max_tokens=max_tokens)
else:
raise ValueError(f"Unsupported model type: {model_type}")
except ImportError as e:
logger.error(f"Import error when creating model type {model_type}: {str(e)}")
raise HTTPException(status_code=400, detail=f"Model type {model_type} is not available in this installation")
except Exception as e:
logger.error(f"Error creating model type {model_type}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to initialize model: {str(e)}")
async def test_model_api_key(api_key: str, model_type: str, model_name: str, base_url: Optional[str] = None) -> bool:
"""
Test if the API key is valid for the given model type.
Args:
api_key: The API key to test
model_type: The type of model
model_name: The name of the model
base_url: Custom API endpoint, used by OPENAI_COMPATIBLE
Returns:
bool: True if the API key is valid, False otherwise
"""
try:
# Create a simple model and agent for testing
model = create_model(model_type, api_key, model_name, base_url=base_url)
test_agent = Agent(
name="Test Agent",
model=model,
instructions="You are a test agent. Just respond with 'Test successful.'",
debug_mode=False
)
# Run a simple test query
await test_agent.arun(message="This is a test message.")
return True
except Exception as e:
logger.error(f"API key test failed: {str(e)}")
return False
+33
View File
@@ -0,0 +1,33 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Retry timing for queued work that calls other services (CRM pushes,
webhooks to external apps).
"""
import random
from typing import Optional
def next_attempt_delay(attempts: int, *, base_seconds: float, max_seconds: float,
jitter_seconds: float, retry_after_seconds: Optional[float] = None) -> float:
"""Seconds to wait before attempt number `attempts + 1`.
Exponential (base · 2^attempts) capped at `max_seconds`; a longer
Retry-After from the other service wins; random jitter on top so a burst
of failures doesn't retry in lockstep.
"""
backoff = min(base_seconds * (2 ** attempts), max_seconds)
return max(retry_after_seconds or 0, backoff) + random.uniform(0, jitter_seconds)
+83
View File
@@ -0,0 +1,83 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from datetime import datetime
import pytz
from app.core.logger import get_logger
logger = get_logger(__name__)
# Used when an organization has never configured its hours. Weekdays 9-5 in the
# org's timezone, weekend closed.
DEFAULT_BUSINESS_HOURS = {
'monday': {'start': '09:00', 'end': '17:00', 'enabled': True},
'tuesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
'wednesday': {'start': '09:00', 'end': '17:00', 'enabled': True},
'thursday': {'start': '09:00', 'end': '17:00', 'enabled': True},
'friday': {'start': '09:00', 'end': '17:00', 'enabled': True},
'saturday': {'start': '09:00', 'end': '17:00', 'enabled': False},
'sunday': {'start': '09:00', 'end': '17:00', 'enabled': False},
}
_DEFAULT_START = '09:00'
_DEFAULT_END = '17:00'
def organization_timezone(organization) -> pytz.BaseTzInfo:
"""The org's timezone, falling back to UTC for a missing or bad value."""
tz_name = getattr(organization, 'timezone', None) or 'UTC'
try:
return pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
logger.warning(f"Invalid timezone {tz_name}, using UTC")
return pytz.UTC
def is_within_business_hours(organization) -> bool:
"""Whether the organization is open right now, in its own timezone.
Pure and cheap — no database work and no model call — so it can answer the
widget's "are you around?" on every page load, not just when the AI is
deciding whether to hand a chat over.
Note: hours are compared as minutes-of-day, so a window that wraps midnight
(22:00-02:00) never matches. That is the behaviour this was extracted from;
changing it would change when the AI offers a transfer.
"""
business_hours = getattr(organization, 'business_hours', None) or DEFAULT_BUSINESS_HOURS
now = datetime.now(organization_timezone(organization))
today = business_hours.get(now.strftime('%A').lower(), {
'start': _DEFAULT_START,
'end': _DEFAULT_END,
'enabled': False,
})
if not today.get('enabled', False):
return False
try:
start_hour, start_minute = map(int, today.get('start', _DEFAULT_START).split(':'))
end_hour, end_minute = map(int, today.get('end', _DEFAULT_END).split(':'))
except (ValueError, AttributeError) as e:
logger.error(f"Error parsing business hours: {str(e)}")
start_hour, start_minute = 9, 0
end_hour, end_minute = 17, 0
minutes_now = now.hour * 60 + now.minute
return (start_hour * 60 + start_minute) <= minutes_now <= (end_hour * 60 + end_minute)
+28
View File
@@ -0,0 +1,28 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
The client's IP for keying (rate limits, telling visitors apart). Our nginx
APPENDS the real client IP to any X-Forwarded-For the client sent, so only
the RIGHTMOST entry is trustworthy - the first one can be anything.
"""
from fastapi import Request
def forwarded_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[-1].strip()
return request.client.host if request.client else "unknown"
+109
View File
@@ -0,0 +1,109 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Whether a signup domain resolves in DNS - for a soft warning, never a block:
some people sign up before their site is live.
"""
import asyncio
import socket
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse
LOOKUP_TIMEOUT_SECONDS = 2.0
# getaddrinfo blocks, and a thread can't be cancelled: a dead nameserver holds
# one until the system resolver gives up, long after our timeout. So lookups
# get a small pool of their own (never the event loop's shared one) and never
# queue: when every slot is busy the answer is "can't tell".
MAX_LOOKUPS = 4
_executor = ThreadPoolExecutor(max_workers=MAX_LOOKUPS, thread_name_prefix="domain-check")
_slots = threading.BoundedSemaphore(MAX_LOOKUPS)
# Only these mean "no such name"; anything else (EAI_AGAIN, a failing
# resolver) is a temporary failure and says nothing about the domain.
_NOT_FOUND = {socket.EAI_NONAME, getattr(socket, "EAI_NODATA", socket.EAI_NONAME)}
# The field re-checks on every pause in typing: remember definite answers
# for a while so that doesn't cost a lookup each time.
CACHE_SECONDS = 600
CACHE_LIMIT = 1000
_cache: Dict[str, Tuple[float, bool]] = {}
def bare_host(domain: str) -> str:
"""'https://Example.com/path' -> 'example.com'."""
value = domain.strip()
return (urlparse(value if "//" in value else f"//{value}").hostname or "").lower()
def site_host(domain: str) -> str:
"""An org's site as hostnames compare: 'https://WWW.Acme.com/' -> 'acme.com'.
CORS admits this host and its www., however the domain was typed."""
return bare_host(domain).removeprefix("www.")
def _lookup(host: str) -> Optional[bool]:
try:
socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
return True
except socket.gaierror as e:
return False if e.errno in _NOT_FOUND else None
async def _resolves(host: str) -> Optional[bool]:
if not _slots.acquire(blocking=False):
return None
try:
future = _executor.submit(_lookup, host)
except RuntimeError: # pool shut down
_slots.release()
return None
# Released when the lookup ends - or is cancelled before it ever ran,
# which a release inside _lookup would miss.
future.add_done_callback(lambda _: _slots.release())
return await asyncio.wrap_future(future)
def _combine(answers) -> Optional[bool]:
"""Any address is enough; "doesn't exist" only when every name said so."""
if any(answer is True for answer in answers):
return True
return False if answers and all(answer is False for answer in answers) else None
async def domain_resolves(domain: str) -> Optional[bool]:
"""True/False when DNS answered, None when it couldn't say in time (or the
input isn't a hostname) - callers warn only on a definite False. Asks for
the bare domain and www. together (some sites only publish www), within
one overall time budget."""
host = bare_host(domain)
if not host or "." not in host:
return None
cached = _cache.get(host)
if cached and time.monotonic() - cached[0] < CACHE_SECONDS:
return cached[1]
candidates = {host, f"www.{host.removeprefix('www.')}"}
try:
result = _combine(await asyncio.wait_for(
asyncio.gather(*(_resolves(candidate) for candidate in candidates)), LOOKUP_TIMEOUT_SECONDS
))
except asyncio.TimeoutError:
result = None
if result is not None:
if len(_cache) >= CACHE_LIMIT:
_cache.clear()
_cache[host] = (time.monotonic(), result)
return result
+285
View File
@@ -0,0 +1,285 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Runtime enforcement for the global chat guardrails.
Two checks around every LLM call on visitor text:
- check_inbound: strong-signal injection detection BEFORE inference. Blocking
is governed by GUARDRAIL_INBOUND_ACTION ("off" / "template_only" / "strict");
non-blocking hits are still counted, which is the data that gates ever
turning "strict" on.
- check_output: scans the reply for leaked policy text (replace + record) and
for the model's own scope refusal (record only) — the only place scope
declines become measurable, since chat messages are encrypted at rest.
Every entry point here FAILS OPEN: a guardrail bug must degrade to
"no guardrail", never to "no chat".
"""
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
from app.agents.guardrail_policy import CANARY_STRINGS, looks_like_scope_refusal
from app.core.config import settings
from app.core.logger import get_logger
from app.repositories.guardrail_event import record_guardrail_events
from app.utils.guardrails import (
RULE_FRAME_TOKENS,
RULE_OFFTOPIC_EXERCISE,
detect_injection,
detect_offtopic_exercise,
)
logger = get_logger(__name__)
# What the visitor sees when a message is blocked pre-inference.
BLOCK_REPLY = (
"I can't process that message. If you have a question I can help with, "
"just ask it in plain words."
)
# What replaces a reply that leaked policy text.
LEAK_REPLY = (
"I can't share details about how I'm set up. Is there something else I "
"can help you with?"
)
_EXCERPT_MAX = 300
# Output-side rule ids (the inbound ones live with their patterns in guardrails).
RULE_PROMPT_LEAK = "injection.prompt_leak"
RULE_MODEL_REFUSED = "offtopic.model_refused"
class Surface:
"""Where a guardrail event came from — the values stored in
guardrail_events.surface."""
WIDGET = "widget"
CHANNEL = "channel"
WORKFLOW = "workflow"
HELP_CENTER = "help_center"
def offtopic_reply(org_name: Optional[str]) -> str:
"""The scope decline, used when an exercise brief is stopped in code. Says
the same thing the policy asks the model to say, so a visitor cannot tell
which layer answered."""
who = (org_name or "").strip() or "this business"
return (
f"I can only help with questions about {who}. "
f"What can I help you with?"
)
@dataclass(frozen=True)
class InboundVerdict:
rule_ids: Tuple[str, ...] = ()
block: bool = False
matched: Tuple[str, ...] = ()
reply: str = BLOCK_REPLY
@property
def triggered(self) -> bool:
return bool(self.rule_ids)
def as_attributes(self) -> dict:
"""chat_history.attributes payload — rule ids only, the column is
unencrypted plain JSON."""
if not self.triggered:
return {}
return {
"guardrail": {
"rules": list(self.rule_ids),
"action": "blocked" if self.block else "counted",
}
}
def _record(
*,
surface: str,
layer: str,
rules: Tuple[str, ...],
action: str,
ctx=None,
session_id: Optional[str] = None,
text: Optional[str] = None,
) -> None:
"""Log line first (always), then one best-effort write for all rules.
`matched` carries the rule ids only. The matched substring would be the
visitor's own words, and this column is plain JSON while `excerpt` is
encrypted — reviewable text belongs there, not here.
"""
org_id = getattr(ctx, "org_id", None)
agent_id = getattr(ctx, "agent_id", None)
for rule in rules:
logger.warning(
f"GUARDRAIL_EVENT rule={rule} layer={layer} action={action} "
f"surface={surface} org={org_id} agent={agent_id} "
f"session={session_id} len={len(text) if text else 0}"
)
if not getattr(settings, "GUARDRAIL_EVENTS_ENABLED", True):
return
excerpt = None
if text and getattr(settings, "GUARDRAIL_STORE_EXCERPT", True):
excerpt = text[:_EXCERPT_MAX]
# One session for the whole verdict: an attack tripping several rules used
# to open a connection per rule.
record_guardrail_events(
rules=rules,
surface=surface,
layer=layer,
action=action,
org_id=org_id,
agent_id=agent_id,
session_id=session_id,
char_len=len(text) if text else None,
excerpt=excerpt,
)
def _business_terms(ctx) -> Tuple[str, ...]:
"""Org name and the domain's own label — a message naming either is about
the business, whatever else it contains."""
terms = []
for attr in ("org_name", "domain"):
value = getattr(ctx, attr, None)
if isinstance(value, str) and value.strip():
terms.append(value.strip())
if attr == "domain" and "." in value:
terms.append(value.split(".", 1)[0])
return tuple(terms)
def _default_scope_in_force(ctx) -> bool:
"""True only when the agent runs the shipped scope rule unmodified."""
try:
if not getattr(ctx, "guardrail_enabled", True):
return False
custom = getattr(ctx, "guardrail_prompt", None)
return not (isinstance(custom, str) and custom.strip())
except Exception:
return True
def _should_block(rule_ids: Tuple[str, ...]) -> bool:
mode = (getattr(settings, "GUARDRAIL_INBOUND_ACTION", "template_only") or "off").lower()
if mode == "strict":
return bool(rule_ids)
if mode == "template_only":
return RULE_FRAME_TOKENS in rule_ids
return False
def check_inbound(
text: Optional[str],
ctx=None,
surface: str = Surface.WIDGET,
session_id: Optional[str] = None,
allow_block: bool = True,
) -> InboundVerdict:
"""Pre-LLM injection check on a visitor message.
`allow_block=False` forces count-only regardless of mode — used on paths
that must never short-circuit (the workflow LLM node owns its own control
flow and message storage).
"""
try:
result = detect_injection(text or "")
rules = list(result.rule_ids)
block = allow_block and _should_block(result.rule_ids)
reply = BLOCK_REPLY
# Off-topic exercise briefs: prompt text could not hold these, so they
# are stopped here — which also avoids paying for the inference the
# whole change exists to prevent.
#
# Only when the tenant is running the DEFAULT scope rule. The detector
# encodes our default's assumptions (an algorithms brief is off-topic),
# which is wrong for a coding bootcamp or a maths tutor — and blocking
# pre-inference gives the model no chance to disagree. If they switched
# the rule off or wrote their own, this must not fire, or the dashboard
# would say one thing and the code do another.
if _default_scope_in_force(ctx) and detect_offtopic_exercise(
text or "", _business_terms(ctx)
):
rules.append(RULE_OFFTOPIC_EXERCISE)
mode = (getattr(settings, "GUARDRAIL_OFFTOPIC_ACTION", "block") or "off").lower()
if allow_block and mode == "block":
block = True
reply = offtopic_reply(getattr(ctx, "org_name", None))
if not rules:
return InboundVerdict()
_record(
surface=surface,
layer="inbound",
rules=tuple(rules),
action="blocked" if block else "counted",
ctx=ctx,
session_id=session_id,
text=text,
)
return InboundVerdict(
rule_ids=tuple(rules), block=block, matched=result.matched, reply=reply
)
except Exception as e:
logger.error(f"Inbound guardrail check failed open: {e}")
return InboundVerdict()
def check_output(
message: Optional[str],
ctx=None,
surface: str = Surface.WIDGET,
session_id: Optional[str] = None,
) -> Tuple[Optional[str], List[str]]:
"""Post-LLM output check.
Returns (possibly replaced message, rule ids recorded). A canary hit —
policy text leaking into the reply — replaces the message; the model's
own scope refusal is only counted, never altered.
"""
try:
if not message or not getattr(settings, "GUARDRAIL_OUTPUT_CHECK_ENABLED", True):
return message, []
if any(canary in message for canary in CANARY_STRINGS):
_record(
surface=surface,
layer="output",
rules=(RULE_PROMPT_LEAK,),
action="replaced",
ctx=ctx,
session_id=session_id,
text=message,
)
return LEAK_REPLY, [RULE_PROMPT_LEAK]
if looks_like_scope_refusal(message):
_record(
surface=surface,
layer="output",
rules=(RULE_MODEL_REFUSED,),
action="counted",
ctx=ctx,
session_id=session_id,
text=message,
)
return message, [RULE_MODEL_REFUSED]
return message, []
except Exception as e:
logger.error(f"Output guardrail check failed open: {e}")
return message, []
+574
View File
@@ -0,0 +1,574 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple
from enum import Enum
from app.core.logger import get_logger
logger = get_logger(__name__)
class GuardrailType(str, Enum):
"""Types of guardrails that can be applied"""
PII = "pii"
JAILBREAK = "jailbreak"
class GuardrailAction(str, Enum):
"""Actions to take when guardrail is triggered"""
BLOCK = "block"
REDACT = "redact"
WARNING = "warning"
LOG = "log"
class GuardrailResult:
"""Result of guardrail evaluation"""
def __init__(
self,
passed: bool,
guardrail_type: GuardrailType,
violations: List[str] = None,
redacted_text: Optional[str] = None,
confidence: float = 0.0
):
self.passed = passed
self.guardrail_type = guardrail_type
self.violations = violations or []
self.redacted_text = redacted_text
self.confidence = confidence
def to_dict(self) -> Dict:
"""Convert result to dictionary"""
return {
"passed": self.passed,
"guardrail_type": self.guardrail_type.value,
"violations": self.violations,
"redacted_text": self.redacted_text,
"confidence": self.confidence
}
class PIIDetector:
"""
Detector for Personally Identifiable Information (PII)
Detects common PII patterns like emails, phone numbers, SSNs, credit cards, etc.
"""
# Regex patterns for common PII types
PATTERNS = {
"email": r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
"phone": r'\b(?:\+?1[-.]?)?\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})\b',
"ssn": r'\b\d{3}-\d{2}-\d{4}\b',
"credit_card": r'\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b',
"ip_address": r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b',
"date_of_birth": r'\b(?:0?[1-9]|1[0-2])[/-](?:0?[1-9]|[12][0-9]|3[01])[/-](?:19|20)\d{2}\b',
# Add more patterns as needed
}
@staticmethod
def detect(text: str, action: GuardrailAction = GuardrailAction.BLOCK) -> GuardrailResult:
"""
Detect PII in text
Args:
text: Text to scan for PII
action: Action to take (BLOCK, REDACT, WARNING, LOG)
Returns:
GuardrailResult with detection results
"""
violations = []
redacted_text = text
for pii_type, pattern in PIIDetector.PATTERNS.items():
matches = re.finditer(pattern, text)
for match in matches:
violations.append(f"{pii_type} at position {match.start()} (length {len(match.group())})")
# Redact if action is REDACT
if action == GuardrailAction.REDACT:
redacted_text = redacted_text.replace(
match.group(),
f"[{pii_type.upper()}_REDACTED]"
)
passed = len(violations) == 0
# Calculate confidence based on pattern matching (simple heuristic)
confidence = 1.0 if violations else 0.0
logger.info(f"PII Detection - Passed: {passed}, Violations: {len(violations)}")
return GuardrailResult(
passed=passed,
guardrail_type=GuardrailType.PII,
violations=violations,
redacted_text=redacted_text if action == GuardrailAction.REDACT else None,
confidence=confidence
)
# --- Injection pattern sets -------------------------------------------------
#
# Two tiers. A strong (Tier A) pattern is high-precision evidence of a prompt
# injection and scores 1.0 on its own; weak (Tier B) signals are phrasings that
# only matter in combination and score 0.25 each. When the message contains
# technical content (code, logs, shell commands) the weak subtotal is halved —
# support chats are full of pasted snippets that echo these phrasings.
#
# Deliberately NOT detected — each is normal in a support conversation, and
# removing them is a contract backed one-for-one by tests
# (tests/utils/test_guardrails_jailbreak.py):
# "sudo" self-hosting / CLI help
# "debug mode", "developer mode" real product features (bare mention)
# "theoretically" ordinary hedging
# bare "DAN" the first name Dan (the old pattern matched
# it because text was lowercased first)
# "ignore/disregard my previous message|ticket|email"
# an override needs an instruction NOUN
# bare "what are your instructions"
# "...for returning an item?" is a product
# question; disclosure is the prompt policy's job
# Literal chat-template tokens. A human visitor never types these; they only
# appear when someone is smuggling a fake system/assistant turn.
_TEMPLATE_MARKER_PATTERNS = [
re.compile(r'<\|im_(?:start|end)\|>'),
re.compile(r'<\|eot_id\|>'),
re.compile(r'<\|(?:system|user|assistant|endoftext)\|>'),
re.compile(r'\[/?INST\]'),
re.compile(r'<<SYS>>'),
re.compile(r'\bBEGIN SYSTEM PROMPT\b', re.IGNORECASE),
re.compile(r'(?m)^\s*\[SYSTEM\]\s*:?\s*$', re.IGNORECASE),
]
# "ignore the previous..." only counts with an instruction noun in range:
# "ignore my previous message" is normal traffic, "ignore your previous
# instructions" is not.
_OVERRIDE_PATTERNS = [
re.compile(
r'\b(?:ignore|disregard|forget|override|bypass)\b[^.\n]{0,40}?'
r'\b(?:previous|prior|above|earlier|initial|original|system|all)\b[^.\n]{0,20}?'
r'\b(?:instructions?|rules?|prompts?|directives?|guidelines?)\b',
re.IGNORECASE,
),
re.compile(
r'\byour\s+(?:new|real|actual|true|updated)\s+'
r'(?:instructions?|rules?|system\s+prompt)\s+(?:are|is)\b',
re.IGNORECASE,
),
re.compile(
r'\bthis\s+(?:overrides|supersedes|replaces|cancels)\s+'
r'(?:all|any|everything|your|the)\b',
re.IGNORECASE,
),
]
_EXFIL_PATTERNS = [
re.compile(
r'\b(?:reveal|repeat|print|output|show|display|dump|recite|reproduce)\b'
r'[^.\n]{0,40}?'
r'\b(?:system\s+(?:prompt|message)|initial\s+(?:prompt|instructions)'
r'|original\s+instructions|instructions\s+verbatim|prompt\s+verbatim'
r'|everything\s+above)\b',
re.IGNORECASE,
),
]
_PERSONA_PATTERNS = [
re.compile(
r'\byou\s+are\s+now\s+(?:an?\s+)?'
r'(?:dan\b|unrestricted|unfiltered|jailbroken|uncensored'
r'|different\s+(?:assistant|ai|bot|model|persona))',
re.IGNORECASE,
),
re.compile(r'\bdo\s+anything\s+now\b', re.IGNORECASE),
# Case-sensitive on purpose: "DAN mode" is a jailbreak, "dan mode..." in
# lowercase prose is far more likely to be about a person called Dan.
re.compile(r'\bDAN\s+(?:mode|prompt|jailbreak)\b'),
re.compile(
r'\b(?:developer|dev|god|admin(?:istrator)?|unrestricted|jailbreak)\s+'
r'mode\s+(?:on|enabled|activated|engaged)\b',
re.IGNORECASE,
),
re.compile(
r'\bpretend\s+(?:you\s+are|to\s+be)\s+(?:an?\s+)?'
r'(?:unrestricted|uncensored|jailbroken|evil)',
re.IGNORECASE,
),
]
# Stable rule ids, stored in guardrail_events.rule and referenced by the
# runtime layer — named so the two cannot drift apart.
RULE_FRAME_TOKENS = "injection.frame_tokens"
RULE_OVERRIDE_INSTRUCTIONS = "injection.override_instructions"
RULE_PROMPT_EXFIL = "injection.prompt_exfil"
RULE_ROLE_HIJACK = "injection.role_hijack"
_STRONG_PATTERNS = {
RULE_FRAME_TOKENS: _TEMPLATE_MARKER_PATTERNS,
RULE_OVERRIDE_INSTRUCTIONS: _OVERRIDE_PATTERNS,
RULE_PROMPT_EXFIL: _EXFIL_PATTERNS,
RULE_ROLE_HIJACK: _PERSONA_PATTERNS,
}
_WEAK_SIGNAL_PATTERNS = [
re.compile(r'\bact\s+(?:as|like)\s+an?\b', re.IGNORECASE),
re.compile(r'\bfrom\s+now\s+on\b', re.IGNORECASE),
re.compile(r'\brole[- ]?play\b', re.IGNORECASE),
re.compile(r'\bpretend\s+(?:you\s+are|to\s+be)\b', re.IGNORECASE),
re.compile(r'\bhypothetically\b', re.IGNORECASE),
re.compile(r'\bfor\s+educational\s+purposes\b', re.IGNORECASE),
re.compile(
r'\bin\s+an?\s+(?:fictional|hypothetical|alternate)\s+'
r'(?:scenario|world|universe)\b',
re.IGNORECASE,
),
re.compile(
r'\bno\s+(?:restrictions?|limitations?|rules|boundaries|filters?)\b',
re.IGNORECASE,
),
re.compile(
r'\bwithout\s+(?:any\s+)?(?:restrictions?|limits?|limitations?|filters?)\b',
re.IGNORECASE,
),
re.compile(
r'\byou\s+(?:must|should|have\s+to)\s+(?:obey|follow|comply)\b',
re.IGNORECASE,
),
re.compile(r'\bunrestricted\s+mode\b', re.IGNORECASE),
re.compile(
r'\bwhat\s+(?:is|are)\s+your\s+(?:instructions|rules|system\s+prompt)\b',
re.IGNORECASE,
),
re.compile(r'\btotally\s+different\s+persona\b', re.IGNORECASE),
]
# Markers of pasted technical content (code, logs, shell). Their presence
# halves the weak-signal score — see the suppressor note above.
_TECHNICAL_CONTEXT_PATTERNS = [
re.compile(r'```'),
re.compile(r'Traceback \(most recent call last\)'),
re.compile(r'File "[^"]+", line \d+'),
re.compile(r'(?m)^\s*(?:\$ |sudo |npm |pip |docker |curl |git )'),
re.compile(r'\{"'),
re.compile(r'\b(?:GET|POST|PUT|PATCH|DELETE)\s+/\S+'),
re.compile(r'\bat\s+\w+\('),
re.compile(r'\S+\.(?:log|ya?ml|env|conf)\b'),
]
_WEAK_SIGNAL_WEIGHT = 0.25
_TECHNICAL_SUPPRESSION = 0.5
# --- Off-topic exercise briefs ----------------------------------------------
#
# Prompt text alone does not hold here. A long, well-structured algorithms or
# system-design brief was answered in full in production despite the policy
# forbidding it, three wordings running — the model reads the length and polish
# as "technical depth" and helps. This detector catches only that narrow shape,
# deterministically, before inference.
#
# Precision comes from the THIRD condition: a real customer question about the
# product almost always names something about the business or its failure mode.
# An exercise brief names none of it. All three must hold, so a pasted traceback
# ("error", "exception"), a long integration question ("webhook", "api key") or
# anything naming the org is never caught.
RULE_OFFTOPIC_EXERCISE = "offtopic.exercise_brief"
_EXERCISE_MIN_CHARS = 500
_EXERCISE_MIN_MARKERS = 4
_EXERCISE_MARKERS = [
re.compile(r'\bO\(\s*(?:1|n|log\s*n|n\s*log\s*n|n\^?2)\s*\)', re.IGNORECASE),
re.compile(r'\b(?:time|space)\s+complexity\b', re.IGNORECASE),
re.compile(r'\bdata\s+structure\b', re.IGNORECASE),
re.compile(r'\balgorithm\b', re.IGNORECASE),
re.compile(r'(?m)^\s*constraints\s*:', re.IGNORECASE),
re.compile(r'\byour\s+task\s*:', re.IGNORECASE),
re.compile(r'\bdesign\s+(?:a|an)\b[^.\n]{0,60}\bthat\s+supports\b', re.IGNORECASE),
re.compile(r'\bimplement\s+the\b', re.IGNORECASE),
re.compile(r'(?m)^\s*bonus\s*:', re.IGNORECASE),
re.compile(r'\bedge\s+cases?\b', re.IGNORECASE),
re.compile(r'\bgiven\s+(?:an?\s+)?(?:array|string|list|integer|tree|graph)\b', re.IGNORECASE),
re.compile(r'\bduplicates?\s+(?:are\s+)?allowed\b', re.IGNORECASE),
re.compile(r'\bworst[- ]case\b', re.IGNORECASE),
]
# Any one of these means the message is anchored to a real business context.
_BUSINESS_ANCHORS = [
re.compile(r'\b(?:pricing|price|plan|billing|invoice|subscription|refund|trial|upgrade|quote)\b', re.IGNORECASE),
re.compile(r'\b(?:account|login|log in|sign ?in|sign ?up|password|onboard)\b', re.IGNORECASE),
re.compile(r'\b(?:install|setup|set up|deploy|self[- ]host|docker|compose|container)\b', re.IGNORECASE),
re.compile(r'\b(?:widget|dashboard|webhook|api[- ]key|integrat|plugin|embed|sdk)\w*', re.IGNORECASE),
re.compile(r'\b(?:error|exception|traceback|stack trace|bug|crash|fail|broken|not working)\w*', re.IGNORECASE),
re.compile(r'\b(?:support|ticket|agent|chatbot|knowledge base|faq|inbox|conversation)\b', re.IGNORECASE),
re.compile(r'\b(?:your (?:product|service|platform|tool|app)|you offer|do you support)\b', re.IGNORECASE),
]
def detect_offtopic_exercise(text: str, business_terms: Sequence[str] = ()) -> bool:
"""True for a long, self-contained exercise brief with no business context.
Deliberately narrow: it exists to catch the generic-LLM-task abuse that the
prompt policy cannot hold, not to classify topics in general.
"""
body = text or ""
if len(body) < _EXERCISE_MIN_CHARS:
return False
markers = sum(1 for p in _EXERCISE_MARKERS if p.search(body))
if markers < _EXERCISE_MIN_MARKERS:
return False
if any(p.search(body) for p in _BUSINESS_ANCHORS):
return False
lowered = body.lower()
return not any(term and term.lower() in lowered for term in business_terms)
@dataclass(frozen=True)
class InjectionResult:
"""Strong-signal (Tier A) injection evidence for the global guardrail layer."""
rule_ids: Tuple[str, ...]
matched: Tuple[str, ...] # short matched tokens only — safe for plain-JSON storage
@property
def triggered(self) -> bool:
return bool(self.rule_ids)
@property
def has_template_marker(self) -> bool:
return RULE_FRAME_TOKENS in self.rule_ids
def detect_injection(text: str) -> InjectionResult:
"""Scan text for strong injection signals only (no weak/contextual scoring).
This is the pre-LLM entry point used by the global chat guardrail: weak
signals are advisory and never block outside an explicitly configured
workflow guardrails node, so they are not reported here.
"""
rule_ids: List[str] = []
matched: List[str] = []
for rule_id, patterns in _STRONG_PATTERNS.items():
for pattern in patterns:
match = pattern.search(text or "")
if match:
if rule_id not in rule_ids:
rule_ids.append(rule_id)
matched.append(match.group()[:60])
break # one hit per rule id is enough
return InjectionResult(rule_ids=tuple(rule_ids), matched=tuple(matched))
class JailbreakDetector:
"""
Detector for jailbreak / prompt-injection attempts.
Scoring counts DISTINCT patterns (repeating one phrase scores once):
every strong hit scores 1.0, weak signals 0.25 each — halved when the
message contains technical content. `passed = score < sensitivity`, so at
the default 0.7 a single strong hit or three clean weak signals fail.
"""
@staticmethod
def detect(text: str, sensitivity: float = 0.7) -> GuardrailResult:
"""
Detect jailbreak attempts in text
Args:
text: Text to scan for jailbreak attempts
sensitivity: Detection sensitivity (0.0 to 1.0, default 0.7)
Returns:
GuardrailResult with detection results
"""
violations = []
strong = detect_injection(text)
for rule_id, token in zip(strong.rule_ids, strong.matched):
violations.append(f"Jailbreak pattern [{rule_id}]: '{token}'")
weak_hits = 0
for pattern in _WEAK_SIGNAL_PATTERNS:
match = pattern.search(text or "")
if match:
weak_hits += 1
violations.append(f"Suspicious phrase: '{match.group()[:60]}'")
suppression = 1.0
if weak_hits and any(p.search(text or "") for p in _TECHNICAL_CONTEXT_PATTERNS):
suppression = _TECHNICAL_SUPPRESSION
confidence = min(
1.0,
len(strong.rule_ids) + weak_hits * _WEAK_SIGNAL_WEIGHT * suppression,
)
passed = confidence < sensitivity
logger.info(
f"Jailbreak Detection - Passed: {passed}, "
f"Confidence: {confidence:.2f}, "
f"Strong Rules: {list(strong.rule_ids)}, "
f"Weak Signals: {weak_hits}"
)
return GuardrailResult(
passed=passed,
guardrail_type=GuardrailType.JAILBREAK,
violations=violations,
redacted_text=None,
confidence=confidence
)
class GuardrailsEngine:
"""
Main guardrails engine that orchestrates multiple guardrail checks
"""
def __init__(self):
self.pii_detector = PIIDetector()
self.jailbreak_detector = JailbreakDetector()
def evaluate(
self,
text: str,
enabled_guardrails: List[GuardrailType],
actions: Dict[GuardrailType, GuardrailAction] = None,
jailbreak_sensitivity: float = 0.7
) -> Tuple[bool, List[GuardrailResult]]:
"""
Evaluate text against enabled guardrails
Args:
text: Text to evaluate
enabled_guardrails: List of guardrail types to check
actions: Dictionary mapping guardrail types to actions
jailbreak_sensitivity: Sensitivity for jailbreak detection (0.0-1.0)
Returns:
Tuple of (all_passed, list_of_results)
"""
if actions is None:
actions = {}
results = []
all_passed = True
for guardrail_type in enabled_guardrails:
action = actions.get(guardrail_type, GuardrailAction.BLOCK)
if guardrail_type == GuardrailType.PII:
result = self.pii_detector.detect(text, action)
results.append(result)
if not result.passed:
all_passed = False
elif guardrail_type == GuardrailType.JAILBREAK:
result = self.jailbreak_detector.detect(text, jailbreak_sensitivity)
results.append(result)
if not result.passed:
all_passed = False
return all_passed, results
def get_default_block_message(self, results: List[GuardrailResult]) -> str:
"""
Generate a user-friendly message when content is blocked
Args:
results: List of guardrail results
Returns:
Message to display to user
"""
failed_guardrails = [r for r in results if not r.passed]
if not failed_guardrails:
return "Your message has been processed successfully."
if GuardrailType.PII in [r.guardrail_type for r in failed_guardrails]:
return (
"I noticed your message contains sensitive personal information. "
"For your privacy and security, please avoid sharing personal details "
"like email addresses, phone numbers, or other identifying information."
)
if GuardrailType.JAILBREAK in [r.guardrail_type for r in failed_guardrails]:
return (
"I'm designed to help you with legitimate questions and tasks. "
"I can't process requests that ask me to ignore my guidelines or "
"behave in ways I'm not designed for. How else can I assist you?"
)
return (
"I'm unable to process your message as it doesn't meet our "
"content guidelines. Please rephrase your request."
)
# Convenience function for quick guardrail checks
def check_guardrails(
text: str,
guardrail_types: List[str],
pii_action: str = "block",
jailbreak_sensitivity: float = 0.7
) -> Tuple[bool, List[Dict], str]:
"""
Convenience function to check text against guardrails
Args:
text: Text to check
guardrail_types: List of guardrail type strings ("pii", "jailbreak")
pii_action: Action for PII ("block", "redact", "warning", "log")
jailbreak_sensitivity: Sensitivity for jailbreak detection
Returns:
Tuple of (passed, results_list, block_message)
"""
engine = GuardrailsEngine()
# Convert string types to enums
enabled_guardrails = []
actions = {}
for gt in guardrail_types:
try:
guardrail_type = GuardrailType(gt.lower())
enabled_guardrails.append(guardrail_type)
if guardrail_type == GuardrailType.PII:
actions[guardrail_type] = GuardrailAction(pii_action.lower())
except ValueError:
logger.warning(f"Unknown guardrail type: {gt}")
passed, results = engine.evaluate(
text,
enabled_guardrails,
actions,
jailbreak_sensitivity
)
block_message = engine.get_default_block_message(results)
results_dict = [r.to_dict() for r in results]
return passed, results_dict, block_message
+114
View File
@@ -0,0 +1,114 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Curated context-window lookup for the org-configurable chat models, used to
size FAQ-generation batches to the selected model instead of a fixed constant.
Deliberately a small hand-maintained table (kept conservative) rather than a
dependency on litellm's model map; unknown models fall back to a per-provider
floor, and FAQ_CONTEXT_TOKENS_OVERRIDE forces a value for anything exotic.
"""
from app.core.config import settings
# Conservative floors when the model name is unrecognised. Keys match
# AIModelType values, uppercased.
_PROVIDER_DEFAULT_TOKENS = {
"OPENAI": 16_000,
"REPLYPIER": 128_000,
"ANTHROPIC": 200_000,
"DEEPSEEK": 64_000,
"GOOGLE": 32_000,
"GOOGLEVERTEX": 32_000,
"GROQ": 8_000,
"MISTRAL": 32_000,
"HUGGINGFACE": 8_000,
"OLLAMA": 8_000,
"XAI": 128_000,
}
_FALLBACK_TOKENS = 8_000
# Lowercase model-name prefixes; the longest matching prefix wins so
# "gpt-4o" beats "gpt-4" and "mistral-large" beats "mistral".
_MODEL_PREFIX_TOKENS = {
"gpt-5": 400_000,
"gpt-4.1": 1_000_000,
"gpt-4o": 128_000,
"gpt-4-turbo": 128_000,
"gpt-4": 8_000,
"gpt-3.5": 16_000,
"o1": 200_000,
"o3": 200_000,
"o4": 200_000,
"claude": 200_000,
"gemini": 1_000_000,
"deepseek": 64_000,
"llama-3.1": 128_000,
"llama-3.2": 128_000,
"llama-3.3": 128_000,
"llama-4": 128_000,
"mixtral": 32_000,
"mistral-large": 128_000,
"mistral": 32_000,
"grok": 128_000,
"qwen": 32_000,
"kimi": 128_000,
}
# Batch-size math. CHARS_PER_TOKEN is conservative for English prose (~4);
# SAFETY absorbs tokenizer variance and the packed-batch separators.
_PROMPT_OVERHEAD_TOKENS = 2_500 # instructions + existing-questions block
_OUTPUT_TOKENS_PER_FAQ = 120
_OUTPUT_TOKENS_BASE = 500
_CHARS_PER_TOKEN = 3.5
_SAFETY = 0.8
_MIN_FAQS_PER_BATCH = 15
_MAX_FAQS_PER_BATCH = 40
_CHARS_PER_FAQ = 3_000 # scale FAQ yield with batch size so density stays flat
def context_tokens_for(model_type: str, model_name: str) -> int:
"""Best-known context window (tokens) for the configured model."""
if settings.FAQ_CONTEXT_TOKENS_OVERRIDE > 0:
return settings.FAQ_CONTEXT_TOKENS_OVERRIDE
name = (model_name or "").casefold()
best = None
for prefix, tokens in _MODEL_PREFIX_TOKENS.items():
if name.startswith(prefix) and (best is None or len(prefix) > best[0]):
best = (len(prefix), tokens)
if best:
return best[1]
return _PROVIDER_DEFAULT_TOKENS.get((model_type or "").upper(), _FALLBACK_TOKENS)
def output_tokens_for(max_faqs: int) -> int:
"""Output reserve for a structured batch of up to max_faqs FAQs."""
return max_faqs * _OUTPUT_TOKENS_PER_FAQ + _OUTPUT_TOKENS_BASE
def faq_batch_chars_for(model_type: str, model_name: str) -> tuple[int, int]:
"""(batch_chars, max_faqs) for FAQ extraction with this model.
batch_chars is floored at FAQ_MAX_BATCH_CHARS (today's behaviour for
small-context models) and ceilinged at FAQ_MAX_BATCH_CHARS_CEILING — a
quality guard, not a token limit: grounded extraction degrades on very
long contexts. max_faqs scales with the batch so bigger batches don't
reduce total FAQ yield.
"""
context = context_tokens_for(model_type, model_name)
usable = context - _PROMPT_OVERHEAD_TOKENS - output_tokens_for(_MAX_FAQS_PER_BATCH)
raw_chars = int(usable * _CHARS_PER_TOKEN * _SAFETY)
batch_chars = max(settings.FAQ_MAX_BATCH_CHARS, min(raw_chars, settings.FAQ_MAX_BATCH_CHARS_CEILING))
max_faqs = max(_MIN_FAQS_PER_BATCH, min(batch_chars // _CHARS_PER_FAQ, _MAX_FAQS_PER_BATCH))
return batch_chars, max_faqs
+73
View File
@@ -0,0 +1,73 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import re
# E.164: '+', a non-zero country code digit, then 7-14 more digits. Everything
# that identifies a person by phone stores exactly this shape, because
# customers.phone is a unique identity key — two spellings of one number would
# be two "people". re.ASCII because \d otherwise matches every Unicode decimal
# digit (Arabic-Indic and friends), which would let an undialable, unmatchable
# string become an identity key. Deliberately not libphonenumber:
# country-specific length rules aren't worth a dependency when Meta/the SMS
# provider is the real validator and rejects undialable numbers anyway.
_E164 = re.compile(r"^\+[1-9]\d{7,14}$", re.ASCII)
# What people paste: spaces, dashes, dots, parentheses.
_DECORATION = re.compile(r"[\s\-().]")
def normalize_phone(value: str | None) -> str | None:
"""The canonical E.164 form of a human-supplied phone number, or None.
Strict: the '+' country prefix is required (decoration — spaces, dashes,
dots, parentheses — is tolerated). Bare digits are rejected here because a
person typing "1234567890" means a national number, and prepending '+'
would silently reassign it to whatever country owns that prefix. An
identity key corrupted by a guess is worse than a rejected form field.
Non-strings return None rather than raising: values arrive from AI
extraction and form payloads, which can hand over lists or numbers.
"""
if not value or not isinstance(value, str):
return None
candidate = _DECORATION.sub("", value.strip())
return candidate if _E164.fullmatch(candidate) else None
def normalize_msisdn(value: str | None) -> str | None:
"""The canonical E.164 form of a platform-supplied subscriber number.
Lenient about the prefix in the two shapes platforms actually send:
bare digits (a WhatsApp wa_id, a Vonage msisdn — E.164 without the '+')
and the ITU '00' international form some SMS gateways use. Both are
trustworthy in a way human-typed digits are not. Delegates to
normalize_phone so there is exactly one definition of the canonical
shape — the two paths must meet in one column.
"""
if not value or not isinstance(value, str):
return None
candidate = _DECORATION.sub("", value.strip())
if candidate.startswith("00") and candidate[2:].isascii() and candidate[2:].isdigit():
candidate = f"+{candidate[2:]}"
elif not candidate.startswith("+") and candidate.isascii() and candidate.isdigit():
candidate = f"+{candidate}"
return normalize_phone(candidate)
def to_wa_id(phone: str) -> str:
"""Meta addresses WhatsApp users by E.164 without the '+'."""
return phone.lstrip("+")
+174
View File
@@ -0,0 +1,174 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import functools
from fastapi import HTTPException, Request
from app.core.config import settings
from app.core.logger import get_logger
from app.core.redis import get_redis
from typing import Optional, Callable
logger = get_logger(__name__)
class RateLimitConfig:
"""Configuration class for rate limiting settings"""
# Rate limits for instruction generation
INSTRUCTION_GEN_LIMIT = 10 # requests
INSTRUCTION_GEN_WINDOW = 86400 # 24 hours in seconds
INSTRUCTION_GEN_PREFIX = "gen_instruct"
# Add more rate limit configurations here as needed
# Example:
# CHAT_MESSAGE_LIMIT = 100
# CHAT_MESSAGE_WINDOW = 3600 # 1 hour
# CHAT_MESSAGE_PREFIX = "chat_msg"
def format_time_remaining(seconds: int) -> str:
"""Format remaining time into a human-readable string"""
hours = seconds // 3600
minutes = (seconds % 3600) // 60
seconds = seconds % 60
if hours > 0:
time_msg = f"{hours} hours"
if minutes > 0:
time_msg += f" and {minutes} minutes"
elif minutes > 0:
time_msg = f"{minutes} minutes"
if seconds > 0:
time_msg += f" and {seconds} seconds"
else:
time_msg = f"{seconds} seconds"
return time_msg
def check_rate_limit(
redis_client: Optional[object],
key: str,
limit: int,
window: int
) -> None:
"""
Check rate limit for a given key
Args:
redis_client: Redis client instance
key: Redis key for rate limiting
limit: Maximum number of requests allowed
window: Time window in seconds
Raises:
HTTPException: If rate limit is exceeded
"""
if not redis_client:
return
# Get current count
current = redis_client.get(key)
if current is None:
# First request, set initial count
redis_client.setex(key, window, 1)
else:
current = int(current)
if current >= limit:
# Calculate time until reset
ttl = redis_client.ttl(key)
time_msg = format_time_remaining(ttl)
logger.warning(f"Rate limit exceeded for key: {key}")
raise HTTPException(
status_code=429,
detail=f"Rate limit exceeded. You can try again in {time_msg}."
)
# Increment counter
redis_client.incr(key)
def rate_limit(limit: int, window: int, key_prefix: str):
"""
Rate limiting decorator that uses Redis to track request counts.
Args:
limit (int): Maximum number of requests allowed within the window
window (int): Time window in seconds
key_prefix (str): Prefix for the Redis key (e.g., 'gen_instruct', 'api_call')
Returns:
function: Decorated function with rate limiting
"""
def decorator(func: Callable):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
redis_client = get_redis()
if not settings.REDIS_ENABLED or not redis_client:
return await func(*args, **kwargs)
# Get request object from kwargs
request = None
for arg in args:
if isinstance(arg, Request):
request = arg
break
if not request and 'request' in kwargs:
request = kwargs['request']
# Get client IP
client_ip = request.client.host if request and request.client else "unknown"
# Get organization ID from kwargs
org_id = kwargs.get('current_user', {}).organization_id
if not org_id:
logger.warning("No organization ID found for rate limiting")
return await func(*args, **kwargs)
# Create a key specific to this organization and IP
rate_key = f"{key_prefix}:{org_id}:{client_ip}"
# Check rate limit
check_rate_limit(redis_client, rate_key, limit, window)
return await func(*args, **kwargs)
return wrapper
return decorator
def limit_instruction_generation(func: Callable):
"""
Specific rate limiting decorator for instruction generation.
Limits to 10 requests per day per organization/IP.
Args:
func: The function to be decorated
Returns:
function: Decorated function with rate limiting
"""
return rate_limit(
limit=RateLimitConfig.INSTRUCTION_GEN_LIMIT,
window=RateLimitConfig.INSTRUCTION_GEN_WINDOW,
key_prefix=RateLimitConfig.INSTRUCTION_GEN_PREFIX
)(func)
# Add more specific rate limiting decorators here as needed
# Example:
# def limit_chat_messages(func: Callable):
# """Rate limiting decorator for chat messages"""
# return rate_limit(
# limit=RateLimitConfig.CHAT_MESSAGE_LIMIT,
# window=RateLimitConfig.CHAT_MESSAGE_WINDOW,
# key_prefix=RateLimitConfig.CHAT_MESSAGE_PREFIX
# )(func)
+337
View File
@@ -0,0 +1,337 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import json
import re
from typing import Any, Dict, Optional, Type, Union
from app.core.logger import get_logger
from app.models.schemas.chat import ChatResponse
logger = get_logger(__name__)
# What the customer sees when a turn fails, instead of anything half-made.
GENERIC_ERROR_MESSAGE = "I apologize, but I encountered an error, please try again later."
# The reply's "message" string, only when it arrived whole (closing quote and all).
_MESSAGE_FIELD = re.compile(r'"message"\s*:\s*"((?:[^"\\]|\\.)*)"', re.DOTALL)
def parse_response_content(response: Any, model: Type[ChatResponse] = ChatResponse) -> ChatResponse:
"""
Parse the response content from the LLM into a ChatResponse object.
Handles various response formats including dictionaries and strings.
Args:
response: The response from the LLM, which can be in various formats
Returns:
ChatResponse: A properly formatted ChatResponse object
"""
response_content = None
try:
# Handle response with content attribute (common in Agno responses)
if hasattr(response, 'content'):
# If content is a dictionary, directly convert to ChatResponse
if isinstance(response.content, dict):
response_content = model(**response.content)
# If content is a string, attempt to extract and parse JSON
elif isinstance(response.content, str):
try:
logger.info(f"Response content is string: {response.content}")
response_content = extract_json_from_text(response.content, model)
except Exception as e:
logger.error(f"Failed to parse response content: {e}")
response_content = create_basic_chat_response(response.content)
# Use content directly if not dict or string
else:
response_content = response.content
# Handle direct response (not wrapped in content attribute)
else:
# If response is a dictionary, directly convert to ChatResponse
if isinstance(response, dict):
response_content = model(**response)
# If response is a string, attempt to extract and parse JSON
elif isinstance(response, str):
try:
response_content = extract_json_from_text(response, model)
except Exception as e:
logger.error(f"Failed to parse response: {e}")
response_content = create_basic_chat_response(response)
# Use response directly if not dict or string
else:
response_content = response
except Exception as e:
logger.error(f"Error in parse_response_content: {e}")
return create_basic_chat_response(str(response) if response else "Error processing response")
# If we still don't have a proper response_content, create a basic one
if not isinstance(response_content, ChatResponse):
text_content = str(response_content) if response_content else "No response generated"
response_content = create_basic_chat_response(text_content)
return response_content
def extract_json_from_text(text: str, model: Type[ChatResponse] = ChatResponse) -> ChatResponse:
"""
Extract JSON from a string and convert it to a ChatResponse object.
Args:
text: The text containing JSON
Returns:
ChatResponse: A properly formatted ChatResponse object
"""
# Clean up the text by removing true/false values outside JSON structure
cleaned_text = clean_malformed_output(text)
# Try to extract JSON in multiple ways
content_dict = None
# Method 1: Handle simple JSON format first
if cleaned_text.startswith('{') and '}' in cleaned_text:
try:
# Try to find a valid JSON substring
end_idx = cleaned_text.find('}') + 1
json_str = cleaned_text[:end_idx]
content_dict = json.loads(json_str)
logger.debug(f"Extracted complete JSON: {content_dict}")
except json.JSONDecodeError:
# If fails, try to handle trailing characters
try:
# Find all closing braces
all_brace_positions = [pos for pos, char in enumerate(cleaned_text) if char == '}']
# Try each closing brace position until we find a valid JSON
for end_pos in all_brace_positions:
try:
json_str = cleaned_text[:end_pos + 1]
content_dict = json.loads(json_str)
logger.debug(f"Extracted JSON with truncation at position {end_pos}: {content_dict}")
break
except json.JSONDecodeError:
continue
except Exception as e:
logger.debug(f"Failed to extract JSON with truncation: {e}")
# Method 2: Handle function call format (e.g., function=search_knowledge_base>{"query": "the dal"} </function>)
if not content_dict:
function_pattern = r'function=(\w+)>({.*?})\s*</function>'
function_match = re.search(function_pattern, cleaned_text, re.DOTALL)
if function_match:
try:
function_name = function_match.group(1)
function_params = json.loads(function_match.group(2))
logger.debug(f"Found function call: {function_name} with params: {function_params}")
# If this is a knowledge base search, we'll create a basic response
if function_name == 'search_knowledge_base':
# The rest of the response after the function call should be the actual content
rest_of_text = cleaned_text.split('</function>', 1)
if len(rest_of_text) > 1 and rest_of_text[1].strip():
content = rest_of_text[1].strip()
try:
# Try to parse the content as JSON
content_dict = json.loads(content)
logger.debug(f"Parsed content after function call as JSON: {content_dict}")
except json.JSONDecodeError:
# If it's not valid JSON, use it as a plain message
logger.debug(f"Content after function call is not valid JSON, using as message")
content_dict = {"message": content}
else:
# If there's no content after the function call, use the query as a message
content_dict = {"message": f"Query: {function_params.get('query', 'No query specified')}"}
except json.JSONDecodeError:
logger.debug("Failed to extract function parameters as JSON")
# Method 3: Use regex to find JSON-like structure
if not content_dict:
json_pattern = r'({.*?})'
match = re.search(json_pattern, cleaned_text, re.DOTALL)
if match:
try:
json_str = match.group(1)
content_dict = json.loads(json_str)
logger.debug(f"Extracted JSON using regex: {content_dict}")
except json.JSONDecodeError:
logger.debug("Failed to extract JSON using regex")
# Method 4: Find JSON by braces (if previous methods didn't work)
if not content_dict:
start_idx = cleaned_text.find('{')
end_idx = cleaned_text.rfind('}') + 1
if start_idx != -1 and end_idx != -1:
try:
json_str = cleaned_text[start_idx:end_idx]
content_dict = json.loads(json_str)
logger.debug(f"Extracted JSON using braces: {content_dict}")
except json.JSONDecodeError:
logger.debug("Failed to extract JSON using braces")
# It set out to be the JSON object we asked for and broke (cut off, malformed):
# never show the customer raw JSON.
if not content_dict and cleaned_text.lstrip().startswith('{'):
return salvage_broken_json(cleaned_text)
# If we failed to extract valid JSON, try to parse potential field values from the text
if not content_dict:
content_dict = extract_fields_from_text(cleaned_text)
# If we successfully extracted a dictionary
if content_dict:
try:
return model(**content_dict)
except Exception as e:
logger.error(f"Failed to create ChatResponse from dictionary: {e}")
# In case of error, use the content dict's message or the original text
message = content_dict.get('message', cleaned_text)
return create_basic_chat_response(message)
# If no JSON structure found, create a basic response
return create_basic_chat_response(cleaned_text)
def salvage_broken_json(text: str) -> ChatResponse:
"""A reply that began as a JSON object but does not parse. Keeps its message if
that field arrived complete; otherwise the generic error, never the fragment."""
match = _MESSAGE_FIELD.search(text)
if match:
try:
message = json.loads(f'"{match.group(1)}"')
if message.strip():
return create_basic_chat_response(message)
except json.JSONDecodeError:
pass
logger.warning(f"Unparseable structured reply, answering with the generic error: {text[:200]}")
return create_basic_chat_response(GENERIC_ERROR_MESSAGE)
def clean_malformed_output(text: str) -> str:
"""
Clean up malformed output with true/false values outside JSON structure.
Args:
text: The text to clean
Returns:
str: Cleaned text
"""
original_text = text
# Replace patterns like "truefalse" with proper JSON values
# This regex finds patterns like "truefalseXXX" and removes them
text = re.sub(r'(true|false)(true|false)', ' ', text)
# Replace literal "true" and "false" that appear outside of quotes
def replace_literals(match):
value = match.group(0)
if value == "true":
return "true"
elif value == "false":
return "false"
return value
# This is a simplified approach - a more robust solution would need to
# identify whether true/false is inside quotes or not
text = re.sub(r'\b(true|false)\b', replace_literals, text)
# Fix malformed image objects in shopify_output
# Pattern 1: "src":"url"}," followed by whitespace/newlines and then ,"alt"
# Should be: "src":"url","alt"
before_fix = text
# This handles: }," \n\n ,"alt"
text = re.sub(r'(\"src\":\"[^\"]+\")\}[,\s\n]*,[,\s\n]*\"alt\"', r'\1,"alt"', text, flags=re.MULTILINE)
if text != before_fix:
logger.warning("Fixed malformed image object: },' with newlines before 'alt' pattern")
# Pattern 2: }," followed by whitespace/newlines and comma before next field
before_fix = text
text = re.sub(r'\}[,\s\n]+,[,\s\n]*\"', ',"', text, flags=re.MULTILINE)
if text != before_fix:
logger.warning("Fixed malformed JSON: },' with newlines pattern")
# Pattern 3: Fix pattern where closing brace appears after URL before comma
# "image":{"src":"url"}," should be "image":{"src":"url",
before_fix = text
text = re.sub(r'(\"image\":\{\"src\":\"[^\"]+\")\},[,\s\n]*\"', r'\1,"', text, flags=re.MULTILINE)
if text != before_fix:
logger.warning("Fixed malformed image closing brace pattern")
if text != original_text:
logger.debug(f"Cleaned JSON from: {original_text[:200]}... to: {text[:200]}...")
return text
def extract_fields_from_text(text: str) -> Dict[str, Any]:
"""
Extract field values from text when JSON parsing fails.
Args:
text: The text to extract field values from
Returns:
Dict[str, Any]: Dictionary of extracted fields
"""
fields = {}
# Extract message content - everything before any markers
message_match = re.search(r'^(.*?)(?:true|false|<|$)', text, re.DOTALL)
if message_match:
fields['message'] = message_match.group(1).strip()
# Extract transfer_to_human
if 'TRANSFER_REQUEST' in text or 'DIRECT_REQUEST' in text:
fields['transfer_to_human'] = True
# Extract transfer reason if present
reason_match = re.search(r'<transfer_reason>(.*?)</transfer_reason>', text)
if reason_match:
fields['transfer_reason'] = reason_match.group(1)
elif 'DIRECT_REQUEST' in text:
fields['transfer_reason'] = 'DIRECT_REQUEST'
# Extract transfer description if present
desc_match = re.search(r'<transfer_description>(.*?)</transfer_description>', text)
if desc_match:
fields['transfer_description'] = desc_match.group(1)
# Extract end_chat flags
if 'end_chat_reason' in text:
fields['end_chat'] = True
return fields
def create_basic_chat_response(message: str) -> ChatResponse:
"""
Create a basic ChatResponse with default values.
Args:
message: The message content
Returns:
ChatResponse: A ChatResponse object with the message and default values
"""
# Extract possible flags from the message
transfer_to_human = False
if "TRANSFER_REQUEST" in message or "DIRECT_REQUEST" in message:
transfer_to_human = True
return ChatResponse(
message=message,
transfer_to_human=transfer_to_human,
end_chat=False,
transfer_reason=None,
transfer_description=None,
request_rating=False,
create_ticket=False
)
+194
View File
@@ -0,0 +1,194 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import re
import html
import urllib.parse
from typing import Optional
def decode_entities(text: str) -> str:
"""Decode HTML entities and URL encoding to catch obfuscated attacks"""
# Decode HTML entities multiple times to catch nested encoding
for _ in range(3):
text = html.unescape(text)
# Decode URL encoding multiple times
for _ in range(3):
try:
text = urllib.parse.unquote(text)
except Exception:
break
return text
def sanitize_message(message: Optional[str]) -> Optional[str]:
"""
Comprehensive sanitization to prevent XSS attacks including encoded payloads.
Blocks:
- All dangerous HTML tags (script, iframe, object, embed, svg, etc.)
- JavaScript URIs (including encoded variants)
- Data URIs (except data:image)
- Event handlers (all on* attributes)
- SVG-based XSS vectors
- Encoded payloads (HTML entities, URL encoding, Unicode)
- CSS expressions and behaviors
- Base64 encoded attacks
- Template literals and ES6 syntax
Args:
message: The message text to sanitize
Returns:
Sanitized message text or None if input is None
"""
if not message:
return message
# SECURITY LAYER 1: Remove markdown link and image syntax to prevent injection
# This must happen BEFORE marked.js processes it on the frontend
# Pattern matches: [text](url) including nested brackets
message = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', message)
# SECURITY: Remove markdown image syntax ![alt](url) - capture alt text without the !
message = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'\1', message)
# SECURITY: Remove reference-style markdown links [text][ref]
message = re.sub(r'\[([^\]]+)\]\[[^\]]+\]', r'\1', message)
# SECURITY: Remove link reference definitions [ref]: url
message = re.sub(r'^\[[^\]]+\]:\s*.+$', '', message, flags=re.MULTILINE)
# SECURITY LAYER 2: Decode any encoded content to catch obfuscated attacks
decoded_message = decode_entities(message)
# Check for encoded javascript: or data: URIs in decoded content
dangerous_patterns = [
r'javascript\s*:',
r'data\s*:(?!image/)',
r'vbscript\s*:',
r'file\s*:',
r'about\s*:',
]
for pattern in dangerous_patterns:
if re.search(pattern, decoded_message, re.IGNORECASE):
# If found in decoded content, sanitize the original too
message = re.sub(pattern, '', message, flags=re.IGNORECASE)
# Remove all dangerous HTML tags (comprehensive list)
# SECURITY: Including <a> and <img> to prevent any external resource loading or clickable links
dangerous_tags = [
'script', 'iframe', 'frame', 'frameset', 'object', 'embed',
'applet', 'base', 'link', 'meta', 'style', 'svg', 'math',
'form', 'input', 'button', 'textarea', 'select', 'option',
'xml', 'xss', 'import', 'video', 'audio', 'track', 'source',
'canvas', 'details', 'template', 'slot', 'noscript',
'marquee', 'bgsound', 'keygen', 'command',
'a', 'img', 'area', 'map' # Block links and images to prevent external resource loading
]
for tag in dangerous_tags:
# Remove opening and closing tags with any attributes
message = re.sub(rf'<{tag}[^>]*>.*?</{tag}>', '', message, flags=re.IGNORECASE | re.DOTALL)
message = re.sub(rf'<{tag}[^>]*/?>', '', message, flags=re.IGNORECASE)
# Remove javascript: URIs (including encoded variants)
message = re.sub(r'j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:', '', message, flags=re.IGNORECASE)
message = re.sub(r'&#[x]?[0-9a-f]+;?', lambda m: decode_entities(m.group()), message)
# Remove vbscript: URIs
message = re.sub(r'vbscript:', '', message, flags=re.IGNORECASE)
# Remove data: URIs that could contain HTML/JS (but preserve data:image)
message = re.sub(r'data:(?!image/)[^,;]*[,;]', '', message, flags=re.IGNORECASE)
# Remove expression() and behavior CSS properties
message = re.sub(r'expression\s*\([^)]*\)', '', message, flags=re.IGNORECASE)
message = re.sub(r'behavior\s*:[^;]*', '', message, flags=re.IGNORECASE)
message = re.sub(r'-moz-binding\s*:', '', message, flags=re.IGNORECASE)
# Remove import statements
message = re.sub(r'@import', '', message, flags=re.IGNORECASE)
# Remove all event handlers (comprehensive list)
event_handlers = [
'onabort', 'onactivate', 'onafterprint', 'onafterupdate', 'onbeforeactivate',
'onbeforecopy', 'onbeforecut', 'onbeforedeactivate', 'onbeforeeditfocus',
'onbeforepaste', 'onbeforeprint', 'onbeforeunload', 'onbeforeupdate', 'onblur',
'onbounce', 'oncellchange', 'onchange', 'onclick', 'oncontextmenu', 'oncontrolselect',
'oncopy', 'oncut', 'ondataavailable', 'ondatasetchanged', 'ondatasetcomplete',
'ondblclick', 'ondeactivate', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave',
'ondragover', 'ondragstart', 'ondrop', 'onerror', 'onerrorupdate', 'onfilterchange',
'onfinish', 'onfocus', 'onfocusin', 'onfocusout', 'onhelp', 'oninput', 'oninvalid',
'onkeydown', 'onkeypress', 'onkeyup', 'onlayoutcomplete', 'onload', 'onlosecapture',
'onmessage', 'onmousedown', 'onmouseenter', 'onmouseleave', 'onmousemove',
'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel', 'onmove', 'onmoveend',
'onmovestart', 'onpaste', 'onpropertychange', 'onreadystatechange', 'onreset',
'onresize', 'onresizeend', 'onresizestart', 'onrowenter', 'onrowexit', 'onrowsdelete',
'onrowsinserted', 'onscroll', 'onsearch', 'onselect', 'onselectionchange',
'onselectstart', 'onstart', 'onstop', 'onsubmit', 'onunload',
'oncanplay', 'oncanplaythrough', 'oncuechange', 'ondurationchange', 'onemptied',
'onended', 'onloadeddata', 'onloadedmetadata', 'onloadstart', 'onpause', 'onplay',
'onplaying', 'onprogress', 'onratechange', 'onseeked', 'onseeking', 'onstalled',
'onsuspend', 'ontimeupdate', 'onvolumechange', 'onwaiting', 'ontoggle',
'onauxclick', 'ongotpointercapture', 'onlostpointercapture', 'onpointercancel',
'onpointerdown', 'onpointerenter', 'onpointerleave', 'onpointermove', 'onpointerout',
'onpointerover', 'onpointerup', 'onwheel', 'onanimationcancel', 'onanimationend',
'onanimationiteration', 'onanimationstart', 'ontransitioncancel', 'ontransitionend',
'ontransitionrun', 'ontransitionstart', 'onshow', 'onsort', 'onpointerrawupdate'
]
for handler in event_handlers:
# Remove with quotes
message = re.sub(rf'{handler}\s*=\s*["\'][^"\']*["\']', '', message, flags=re.IGNORECASE)
# Remove without quotes
message = re.sub(rf'{handler}\s*=\s*[^>\s]*', '', message, flags=re.IGNORECASE)
# Remove with backticks (template literals)
message = re.sub(rf'{handler}\s*=\s*`[^`]*`', '', message, flags=re.IGNORECASE)
# Remove dangerous attributes
# SECURITY: Including src, href, and all attributes that can load external resources
dangerous_attrs = [
'formaction', 'action', 'form', 'srcdoc', 'srcset', 'dynsrc', 'lowsrc',
'ping', 'poster', 'background', 'code', 'codebase', 'archive', 'profile',
'xmlns', 'xlink:href', 'attributename', 'from', 'to', 'values', 'begin',
'autofocus', 'autoplay', 'controls', 'manifest', 'sandbox',
'src', 'href', 'data', 'icon', 'usemap' # Block all resource-loading attributes
]
for attr in dangerous_attrs:
message = re.sub(rf'{attr}\s*=\s*["\'][^"\']*["\']', '', message, flags=re.IGNORECASE)
message = re.sub(rf'{attr}\s*=\s*[^>\s]*', '', message, flags=re.IGNORECASE)
# Remove HTML comments (can be used to hide malicious code)
message = re.sub(r'<!--.*?-->', '', message, flags=re.DOTALL)
# Remove CDATA sections
message = re.sub(r'<!\[CDATA\[.*?\]\]>', '', message, flags=re.DOTALL | re.IGNORECASE)
# Remove XML processing instructions
message = re.sub(r'<\?.*?\?>', '', message, flags=re.DOTALL)
# Remove null bytes and other control characters that can bypass filters
message = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', message)
# Remove Unicode directional override characters (can hide malicious code)
message = re.sub(r'[\u202a-\u202e\u2066-\u2069]', '', message)
return message
+40
View File
@@ -0,0 +1,40 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import re
# Any scheme prefix, matched case-insensitively so "HTTP://" can't sneak past
# scheme checks or get a second scheme prepended.
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*)://")
def normalize_url(value: str, require_https: bool = False) -> str:
"""Normalize a user-supplied URL: default the scheme to https and lowercase
an existing scheme. Raises ValueError for blank input, non-http(s) schemes,
and — when require_https is set — plain http URLs.
"""
value = value.strip()
if not value:
raise ValueError("URL must not be blank")
match = _SCHEME_RE.match(value)
if match:
scheme = match.group(1).lower()
if scheme not in ("http", "https"):
raise ValueError("URL must use http or https")
if require_https and scheme != "https":
raise ValueError("URL must use https")
return f"{scheme}://{value[match.end():]}"
return f"https://{value}"
+22
View File
@@ -0,0 +1,22 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from uuid import UUID
def as_uuid(value: UUID | str) -> UUID:
"""A UUID from either form. Raises ValueError for anything that isn't one."""
return value if isinstance(value, UUID) else UUID(str(value))