import inspect import json from turnabout.generation.generator import CaseGenerator class _Message: def __init__(self, content): self.content = content class _Choice: def __init__(self, content): self.message = _Message(content) class _Completion: def __init__(self, content): self.choices = [_Choice(content)] class _Completions: def __init__(self, payloads): self._payloads = list(payloads) def create(self, **_kwargs): payload = self._payloads.pop(0) return _Completion("```json\n" + json.dumps(payload) + "\n```") class _Chat: def __init__(self, payloads): self.completions = _Completions(payloads) class _OpenAIClient: def __init__(self, payloads): self.chat = _Chat(payloads) class APITimeoutError(Exception): pass class _TimeoutCompletions: def __init__(self): self.calls = 0 def create(self, **_kwargs): self.calls += 1 raise APITimeoutError("request timed out") class _TimeoutChat: def __init__(self): self.completions = _TimeoutCompletions() class _TimeoutOpenAIClient: def __init__(self): self.chat = _TimeoutChat() class RateLimitError(Exception): status_code = 429 class _RateLimitCompletions: def __init__(self): self.calls = 0 def create(self, **_kwargs): self.calls += 1 raise RateLimitError("Error code: 429 - Too many requests") class _RateLimitChat: def __init__(self): self.completions = _RateLimitCompletions() class _RateLimitOpenAIClient: def __init__(self): self.chat = _RateLimitChat() def _easy_case_payloads(): return [ { "title": "The Clocktower Switch", "description": "A staged theft hides a simple timing contradiction.", "defendant": "d_defendant", "true_culprit": "w_witness", "witnesses": ["w_witness"], "crime_type": "theft", }, { "characters": [ { "id": "d_defendant", "name": "Dana Vale", "description": "The accused apprentice.", "role": "defendant", }, { "id": "w_witness", "name": "Milo Kent", "description": "The witness who lied about the time.", "role": "witness", }, ], "evidence": [ { "id": "clock_photo", "name": "Clock Photo", "item_type": "photo", "description": "A photo of the tower clock.", "detail": "The clock shows 8:10, not 8:30.", "source": "pre_given", }, { "id": "repair_log", "name": "Repair Log", "item_type": "document", "description": "A log of the tower maintenance.", "detail": "The clock was stopped for repairs at 8:15.", "source": "pre_given", }, ], }, { "court": { "penalty_limit": 5, "rounds": [ { "witness_id": "w_witness", "order": 1, "initial_testimony_id": "testimony_1", "testimonies": [ { "id": "testimony_1", "title": "What I Saw", "witness_id": "w_witness", "preamble": "The witness describes the scene.", "statements": [ { "id": "stmt_time", "text": "I saw Dana at the tower at 8:30.", "press_response": "I am certain about the time.", "contradiction": { "evidence_id": "clock_photo", "explanation": ( "The clock photo proves the " "witness saw a different time." ), "is_primary": True, }, } ], } ], } ], "win_condition": { "required_contradiction_ids": ["stmt_time"], "final_evidence_id": "clock_photo", }, } }, ] def _hard_case_payloads_with_investigation_retry(): payloads = _easy_case_payloads() payloads[1]["evidence"].append( { "id": "kitchen_access_log", "name": "Kitchen Access Log", "item_type": "document", "description": "A record of kitchen door access.", "detail": "The witness entered the kitchen before the theft.", "source": "investigation", "acquisition": { "method": "examine", "location_id": "main_kitchen", "target_id": "kitchen_access_log_terminal", "prerequisites": [], "present_evidence_id": None, }, } ) payloads.append( { "locations": [ { "id": "front_counter", "name": "Front Counter", "description": "The public counter area.", "connected_to": [], "examinables": [], "characters_present": [], "available_from_start": True, "unlock_prerequisites": [], } ], "dialogues": [], "investigation_gate": { "required_evidence": ["kitchen_access_log"], "auto_advance": False, }, } ) payloads.append( { "locations": [ { "id": "main_kitchen", "name": "Main Kitchen", "description": "The kitchen where the access terminal sits.", "connected_to": [], "examinables": [ { "id": "kitchen_access_log_terminal", "name": "Kitchen Access Log Terminal", "description": "A terminal storing access records.", "examined_description": "It shows the witness entered first.", } ], "characters_present": [], "available_from_start": True, "unlock_prerequisites": [], } ], "dialogues": [], "investigation_gate": { "required_evidence": ["kitchen_access_log"], "auto_advance": False, }, } ) return payloads def test_generate_is_sync_and_reports_progress(): generator = CaseGenerator( llm_client=_OpenAIClient(_easy_case_payloads()), backend="openai", ) progress_updates = [] assert not inspect.iscoroutinefunction(generator.generate) assert not inspect.iscoroutinefunction(generator._step_with_retry) case = generator.generate( theme="clocktower", difficulty="easy", progress_callback=lambda value, desc: progress_updates.append( (value, desc) ), ) assert case.title == "The Clocktower Switch" assert case.court.win_condition.required_contradiction_ids == ["stmt_time"] assert progress_updates[0] == (0.25, "Generating narrative...") assert progress_updates[-2] == (0.9, "Assembling and validating case...") assert progress_updates[-1] == (1.0, "Case generated successfully.") def test_generate_events_yields_status_before_final_case(): generator = CaseGenerator( llm_client=_OpenAIClient(_easy_case_payloads()), backend="openai", ) events = list(generator.generate_events(theme="clocktower", difficulty="easy")) assert events[0] == (0.25, "Generating narrative...", None) assert events[-1][0] == 1.0 assert events[-1][1] == "Case generated successfully." assert events[-1][2].title == "The Clocktower Switch" def test_generate_timeout_is_not_retried(): client = _TimeoutOpenAIClient() generator = CaseGenerator( llm_client=client, backend="openai", request_timeout=1, ) try: generator.generate(theme="clocktower", difficulty="easy") except TimeoutError as exc: assert "LLM request timed out during step 'narrative'" in str(exc) else: raise AssertionError("generate() should raise TimeoutError") assert client.chat.completions.calls == 1 def test_generate_rate_limit_is_not_retried(): client = _RateLimitOpenAIClient() generator = CaseGenerator( llm_client=client, backend="openai", request_timeout=1, ) try: generator.generate(theme="clocktower", difficulty="easy") except RuntimeError as exc: assert "LLM gateway rate limit reached during step 'narrative'" in str(exc) assert "LLM_STEP_DELAY_SECONDS" in str(exc) else: raise AssertionError("generate() should raise RuntimeError") assert client.chat.completions.calls == 1 def test_hard_generation_retries_invalid_investigation_without_repair(): generator = CaseGenerator( llm_client=_OpenAIClient(_hard_case_payloads_with_investigation_retry()), backend="openai", ) case = generator.generate(theme="bakery", difficulty="hard") main_kitchen = case.get_location("main_kitchen") assert main_kitchen is not None assert case.get_location("front_counter") is None assert any( ex.id == "kitchen_access_log_terminal" for ex in main_kitchen.examinables )