Files
replypier/backend/tests/agents/test_quick_replies.py
T
WangShen007 54b949b392 chore(brand): rename the product to ReplyPier
The upstream project name is gone from code, assets, docs and build
output: brand strings, domains (replypier.site and its app/api/docs/help
subdomains), env prefixes, image and network names, the widget's public
JS API (window.ReplyPier, replypierId/BaseUrl/Config/Token), CSS tokens
(--rp-*), file and directory names, and the WordPress plugin package.

- widget loader is now webclient/replypier.min.js; both bundles rebuilt
  from source (backend/assets/widget.{js,css}, frontend/public/webclient)
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and config.dev.js
  rewritten rather than string-swapped; the demo config no longer ships
  the previous vendor's Firebase/Razorpay/Google keys
- new migration replypier_ai_model_001 adds REPLYPIER to the managed
  AIModelType and rewrites pre-rename rows
- local compose project, database, role and volume renamed; LOCAL_DEPLOY
  documents carrying the old local data over
2026-10-26 00:51:03 +08:00

171 lines
7.3 KiB
Python

"""
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.
ChatResponse.quick_replies must be acceptable to every structured-output
path, and a bad value from the model must never fail the turn.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
from uuid import uuid4
from openai.lib._pydantic import to_strict_json_schema
from sqlalchemy.orm import Session
from app.agents.chat_agent import (
ChatAgent, _GROQ_JSON_TOOL_SCHEMA, _GROQ_QUICK_REPLIES_PROPERTY,
_build_chat_response_from_capture, drop_stale_choices,
)
from app.agents.choice_prompt import choice_instructions
from app.channels.interactive_limits import MESSENGER_LIMITS, TELEGRAM_LIMITS, WHATSAPP_LIMITS
from app.channels.constants import MAX_CHOICE_LENGTH, MAX_CHOICE_OPTIONS
from app.models.schemas.chat import ChannelChatResponse, ChatResponse
from app.utils.response_parser import parse_response_content
def _response(**fields) -> ChannelChatResponse:
return ChannelChatResponse(message="Pick one", transfer_to_human=False, end_chat=False,
request_rating=False, create_ticket=False, **fields)
def test_openai_strict_schema_accepts_a_string_list():
schema = to_strict_json_schema(ChannelChatResponse)
assert schema["properties"]["quick_replies"]["anyOf"] == [
{"items": {"type": "string"}, "type": "array"}, {"type": "null"}]
def test_agents_not_offered_choices_keep_their_schema():
assert "quick_replies" not in to_strict_json_schema(ChatResponse)["properties"]
assert "quick_replies" not in _GROQ_JSON_TOOL_SCHEMA["properties"]
def test_groq_tool_declares_the_field_only_when_offered():
assert _GROQ_QUICK_REPLIES_PROPERTY["items"] == {"type": "string"}
def test_groq_capture_carries_quick_replies():
response = _build_chat_response_from_capture(
{"message": "When?", "quick_replies": ["Today", "Tomorrow"]}, ChannelChatResponse)
assert response.quick_replies == ["Today", "Tomorrow"]
def test_options_are_cleaned_and_capped_not_rejected():
response = _response(quick_replies=[" Yes ", "yes", "", None, "No"]
+ [f"Option {i}" for i in range(MAX_CHOICE_OPTIONS + 5)])
assert response.quick_replies[:2] == ["Yes", "No"]
assert len(response.quick_replies) == MAX_CHOICE_OPTIONS
def test_object_shaped_options_give_their_text_never_a_repr():
response = _response(quick_replies=[{"title": "Yes"}, {"label": "Maybe"}, {"x": 1}, 7, "No"])
assert response.quick_replies == ["Yes", "Maybe", "No"]
def test_each_option_is_capped():
response = _response(quick_replies=["x" * 500])
assert len(response.quick_replies[0]) == MAX_CHOICE_LENGTH
class TestStaleChoices:
def test_kept_with_the_reply_the_model_wrote(self):
response = _response(quick_replies=["Yes"])
drop_stale_choices(response, "Pick one")
assert response.quick_replies == ["Yes"]
def test_dropped_when_the_guardrail_replaced_the_reply(self):
response = _response(quick_replies=["Yes"])
drop_stale_choices(response, "the model's original text")
assert response.quick_replies is None
def test_dropped_on_a_goodbye(self):
response = _response(quick_replies=["Yes"])
response.end_chat = True
drop_stale_choices(response, "Pick one")
assert response.quick_replies is None
def test_plain_responses_are_untouched(self):
response = ChatResponse(message="x", transfer_to_human=False, end_chat=True,
request_rating=False, create_ticket=False)
drop_stale_choices(response, "y") # no field to clear, must not raise
def test_a_malformed_value_is_dropped():
assert _response(quick_replies="Yes, No").quick_replies is None
assert _response(quick_replies=[]).quick_replies is None
assert _response().quick_replies is None
def _build_agent(choice_limits) -> ChatAgent:
"""A ChatAgent with everything external stubbed, to read its prompt."""
agent_data = Mock()
agent_data.name = "Support"
agent_data.jira_enabled = False
agent_data.transfer_to_human = False
agent_data.ask_for_rating = False
agent_data.ticketing_enabled = False
agent_data.instructions = ["You are a helpful assistant"]
storage = Mock()
storage.get_session_state.return_value = {"status": "active"}
with patch('app.agents.chat_agent.get_db') as mock_get_db, \
patch('app.agents.chat_agent.JiraRepository') as mock_jira_repo, \
patch('app.tools.knowledge_search_byagent.KnowledgeSearchByAgent', return_value=Mock()), \
patch('app.agents.chat_agent.EncryptedPostgresAgentStorage', return_value=storage), \
patch('app.agents.chat_agent.settings.DATABASE_URL', "mock://test"), \
patch.dict('os.environ', {}, clear=True):
mock_get_db.return_value = iter([MagicMock(spec=Session)])
mock_jira_repo.return_value.get_agent_with_jira_config.return_value = agent_data
return ChatAgent(api_key="test-key", model_name="gpt-4", model_type="openai",
org_id=str(uuid4()), agent_id=str(uuid4()),
customer_id=str(uuid4()), session_id=str(uuid4()),
channel="whatsapp", choice_limits=choice_limits)
def _prompt(agent: ChatAgent) -> str:
instructions = agent.agent.instructions
return "\n".join(instructions) if isinstance(instructions, list) else instructions
def test_only_a_channel_with_buttons_gets_the_choice_instructions():
offered, plain = _build_agent(WHATSAPP_LIMITS), _build_agent(None)
assert choice_instructions(WHATSAPP_LIMITS) in _prompt(offered)
assert "tappable choices" not in _prompt(plain)
assert offered.agent.response_model is ChannelChatResponse
assert plain.agent.response_model is ChatResponse
def test_choices_survive_a_reply_parsed_from_text_json():
"""Some models answer with JSON in the text instead of structured output."""
response = parse_response_content(
SimpleNamespace(content='{"message": "When?", "quick_replies": ["Today", "Tomorrow"]}'),
ChannelChatResponse)
assert response.quick_replies == ["Today", "Tomorrow"]
def test_choices_are_dropped_with_an_empty_turn_fallback():
response = _response(quick_replies=["Yes"])
response.message = "I'm sorry, I didn't quite catch that." # the empty-turn fallback
drop_stale_choices(response, "")
assert response.quick_replies is None
def test_the_instructions_follow_the_channels_limits():
assert "up to 3 short options show as buttons, more as a list" in choice_instructions(WHATSAPP_LIMITS)
telegram = choice_instructions(TELEGRAM_LIMITS)
assert "at most 10," in telegram and "more as a list" not in telegram
assert "under 64 characters" in telegram
messenger = choice_instructions(MESSENGER_LIMITS)
assert "at most 10," in messenger and "13" not in messenger