feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
977783c126
commit
9eb8613c05
126 files changed
+17436
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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, []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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("+")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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  - 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
|
||||
@@ -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}"
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user