- LLMProvider interface: AnthropicLLM + ScriptedLLM test fake - System prompt composition from super-admin template + structured tenant data only (§6.3); auto-generated greetings with recording disclosure, out-of-hours variant, chat variant - Agent tools (§6.5): get_salon_info, check_availability (≤8 slots to model, agent offers ≤3), submit_booking_request (caller-ID fallback, hallucinated service-id guard), take_message - Conversation loop with tool dispatch, round-limit guard, transcript capture, outcome classification (request_created/message_taken/info_only/abandoned) - Terminal playground (python -m gogo.agent.cli) + demo seed (python -m gogo.seed) - Deterministic loop tests (ScriptedLLM) + Appendix A golden-scenario acceptance tests against a real LLM (skipped without an API key) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
173 lines
6.9 KiB
Python
173 lines
6.9 KiB
Python
"""Appendix A golden-scenario acceptance tests against a REAL LLM.
|
|
|
|
Skipped unless GOGO_ANTHROPIC_API_KEY or ANTHROPIC_API_KEY is set (they spend
|
|
tokens). Each scenario drives a text-mode conversation and asserts the spec's
|
|
acceptance criteria: correct tool calls, no "confirmed" language, at most 3
|
|
offered slots, proper closing (§ Appendix A / M2).
|
|
|
|
Run just these: pytest tests/test_acceptance_appendix_a.py -v
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from gogo.agent.llm import AnthropicLLM
|
|
from gogo.agent.loop import AgentConversation
|
|
|
|
API_KEY = os.environ.get("GOGO_ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_API_KEY")
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not API_KEY, reason="no Anthropic API key set — real-LLM acceptance tests skipped"
|
|
)
|
|
|
|
# §6.4: never present the booking as final. ("radi potvrde" is fine; "potvrđen" is not)
|
|
FORBIDDEN = ["potvrđen", "potvrdjen", "rezervisan", "rezerviran", "bukiran", "zakazan"]
|
|
|
|
TIME_MENTION = re.compile(r"\b\d{1,2}[:.]\d{2}\b|\bu (jedan|dva|tri|četiri|pet|šest|sedam|osam|devet|deset|jedanaest|dvanaest)\b")
|
|
|
|
|
|
def assert_no_confirmed_language(text: str) -> None:
|
|
lower = text.lower()
|
|
for word in FORBIDDEN:
|
|
assert word not in lower, f"forbidden 'confirmed' language {word!r} in: {text}"
|
|
|
|
|
|
def count_offered_times(text: str) -> int:
|
|
return len(TIME_MENTION.findall(text))
|
|
|
|
|
|
@pytest.fixture
|
|
def llm():
|
|
return AnthropicLLM(api_key=API_KEY)
|
|
|
|
|
|
@pytest.fixture
|
|
async def convo(session, tenant, emails, sms, clean_mock_provider, llm):
|
|
"""Voice-channel conversation with caller-ID, mock provider, no busy blocks."""
|
|
c = AgentConversation(
|
|
session, tenant, channel="voice", caller_phone="+38765123456", llm=llm
|
|
)
|
|
await c.greeting()
|
|
return c
|
|
|
|
|
|
def tool_names(convo: AgentConversation) -> list[str]:
|
|
return [name for name, _ in convo.executor.tool_calls]
|
|
|
|
|
|
async def test_a1_happy_path_booking(convo, session, emails):
|
|
"""A.1: booking with a preferred time → ≤3 slots, request submitted, closing line."""
|
|
r1 = await convo.user_turn("Dobar dan, htjela bih zakazati šišanje i feniranje.")
|
|
assert_no_confirmed_language(r1)
|
|
|
|
r2 = await convo.user_turn("Nešto u srijedu poslijepodne ako ima.")
|
|
assert "check_availability" in tool_names(convo)
|
|
assert count_offered_times(r2) <= 3
|
|
assert_no_confirmed_language(r2)
|
|
|
|
r3 = await convo.user_turn("Može prvi termin koji ste rekli. Amra Hodžić.")
|
|
r4 = r3
|
|
if "submit_booking_request" not in tool_names(convo):
|
|
r4 = await convo.user_turn("Jeste, broj s kojeg zovem je pravi broj.")
|
|
assert "submit_booking_request" in tool_names(convo)
|
|
assert_no_confirmed_language(r4)
|
|
|
|
final = " ".join(t.text for t in convo.turns if t.role == "assistant")
|
|
# closing: forwards the request, salon will contact
|
|
assert "kontaktira" in final.lower() or "javi" in final.lower()
|
|
assert convo.outcome == "request_created"
|
|
assert len(emails) == 1 # proposal email to the owner
|
|
|
|
|
|
async def test_a2_price_question_only(convo, session, emails):
|
|
"""A.2: price-only call — range answered (price_mode=range for Merima seed is
|
|
'exact' in fixture; assert amount present), one gentle booking offer, no request."""
|
|
r1 = await convo.user_turn("Koliko košta šišanje i feniranje?")
|
|
assert re.search(r"\d+", r1), f"expected a price in: {r1}"
|
|
assert_no_confirmed_language(r1)
|
|
|
|
r2 = await convo.user_turn("Ne, samo sam htjela cijenu. Hvala.")
|
|
assert_no_confirmed_language(r2)
|
|
assert "submit_booking_request" not in tool_names(convo)
|
|
assert convo.outcome == "info_only"
|
|
assert emails == []
|
|
|
|
|
|
async def test_a3_unsure_client_no_advice(convo, session, emails):
|
|
"""A.3: agent must not give treatment advice — routes decision to the professional."""
|
|
r1 = await convo.user_turn(
|
|
"Uništila mi se kosa od peglanja, ne znam šta bi mi pomoglo..."
|
|
)
|
|
# must not recommend one treatment as the right one; the professional decides
|
|
assert_no_confirmed_language(r1)
|
|
|
|
r2 = await convo.user_turn(
|
|
"Može, super. Radnim danom poslije četiri mi odgovara. Lejla Begić, broj je ovaj."
|
|
)
|
|
r3 = ""
|
|
if "submit_booking_request" not in tool_names(convo):
|
|
r3 = await convo.user_turn("Jeste, taj broj. Može.")
|
|
assert "submit_booking_request" in tool_names(convo) or "take_message" in tool_names(convo)
|
|
for t in (r2, r3):
|
|
assert_no_confirmed_language(t)
|
|
|
|
|
|
async def test_a4_cancellation_takes_message(convo, session):
|
|
"""A.4: cancellation → take_message, no calendar promises."""
|
|
await convo.user_turn("Trebala bih otkazati termin za sutra.")
|
|
r2 = await convo.user_turn("Selma Kovač, sutra u deset.")
|
|
if "take_message" not in tool_names(convo):
|
|
r2 = await convo.user_turn("To je sve, hvala.")
|
|
assert "take_message" in tool_names(convo)
|
|
assert convo.outcome == "message_taken"
|
|
assert_no_confirmed_language(r2)
|
|
lower = r2.lower()
|
|
assert "slobod" not in lower # must not promise the slot is freed
|
|
|
|
|
|
async def test_a5_out_of_hours_greeting(session, tenant, emails, sms, clean_mock_provider, llm):
|
|
"""A.5: out-of-hours greeting states hours and offers booking."""
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from gogo.agent.prompt import compose_greeting
|
|
|
|
closed_at = datetime(2026, 7, 15, 21, 30, tzinfo=ZoneInfo("Europe/Sarajevo"))
|
|
greeting = compose_greeting(tenant, "voice", closed_at)
|
|
assert "zatvoren" in greeting
|
|
assert "radno vrijeme" in greeting
|
|
assert "razgovor se snima" in greeting
|
|
|
|
|
|
async def test_a6_two_clarifications_then_message(convo, session):
|
|
"""A.6: unintelligible caller → max 2 clarification attempts, then message + exit."""
|
|
r1 = await convo.user_turn("mrmlj hrm brbl nrzm")
|
|
r2 = await convo.user_turn("hmpf mrm vrm hrm")
|
|
r3 = await convo.user_turn("brm hrm mrmlj")
|
|
assert "take_message" in tool_names(convo), "expected take_message after failed clarifications"
|
|
final = (r2 + " " + r3).lower()
|
|
assert "nazva" in final or "javi" in final or "poruk" in final # promises a callback
|
|
for t in (r1, r2, r3):
|
|
assert_no_confirmed_language(t)
|
|
|
|
|
|
async def test_a7_home_visit_collects_address(convo, session, emails):
|
|
"""A.7: home-visit service → collects area, home_visit flag set on the request."""
|
|
await convo.user_turn("Da li vi dolazite kući za pedikir? Majka mi je nepokretna.")
|
|
await convo.user_turn("Naselje Lauš. Bilo koje prijepodne ove sedmice.")
|
|
r3 = await convo.user_turn("Fatima Softić, a broj je ovaj s kojeg zovem, može.")
|
|
if "submit_booking_request" not in tool_names(convo):
|
|
r3 = await convo.user_turn("Da, to je sve, hvala vam.")
|
|
assert "submit_booking_request" in tool_names(convo)
|
|
|
|
from sqlalchemy import select
|
|
|
|
from gogo.models import BookingRequest
|
|
|
|
req = (await session.execute(select(BookingRequest))).scalar_one()
|
|
assert req.home_visit is True
|
|
assert req.address and "lauš" in req.address.lower()
|
|
assert_no_confirmed_language(r3)
|