Files
gogo-telefon/tests/test_acceptance_appendix_a.py
Senad Uka 0d0838e6e2 M2: agent logic in text mode
- 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>
2026-07-11 09:53:08 +02:00

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)