"""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)