""" Response parser for AI narrative responses. This module handles AI response parsing. Game state changes (items, gold, XP) are now handled exclusively through predetermined dice check outcomes in action templates, not through AI-generated JSON. Quest offers are extracted from NPC dialogue using [QUEST_OFFER:quest_id] markers. """ import re from dataclasses import dataclass, field from typing import Any, Optional import structlog logger = structlog.get_logger(__name__) @dataclass class ItemGrant: """ Represents an item granted by the AI during gameplay. The AI can grant items in two ways: 1. By item_id - References an existing item from game data 2. By name/type/description - Creates a generic item """ item_id: Optional[str] = None # For existing items name: Optional[str] = None # For generic items item_type: Optional[str] = None # consumable, weapon, armor, quest_item description: Optional[str] = None value: int = 0 quantity: int = 1 def is_existing_item(self) -> bool: """Check if this references an existing item by ID.""" return self.item_id is not None def is_generic_item(self) -> bool: """Check if this is a generic item created by the AI.""" return self.item_id is None and self.name is not None @dataclass class GameStateChanges: """ Structured game state changes extracted from AI response. These changes are validated and applied to the character after the AI generates its narrative response. """ items_given: list[ItemGrant] = field(default_factory=list) items_taken: list[str] = field(default_factory=list) # item_ids to remove gold_given: int = 0 gold_taken: int = 0 experience_given: int = 0 quest_offered: Optional[str] = None # quest_id quest_completed: Optional[str] = None # quest_id location_change: Optional[str] = None @dataclass class ParsedAIResponse: """ Complete parsed AI response with narrative and game state changes. Attributes: narrative: The narrative text to display to the player game_changes: Structured game state changes to apply raw_response: The original unparsed response from AI parse_success: Whether parsing succeeded parse_errors: Any errors encountered during parsing """ narrative: str game_changes: GameStateChanges raw_response: str parse_success: bool = True parse_errors: list[str] = field(default_factory=list) class ResponseParserError(Exception): """Exception raised when response parsing fails critically.""" pass def parse_ai_response(response_text: str) -> ParsedAIResponse: """ Parse an AI response to extract the narrative text. Game state changes (items, gold, XP) are now handled exclusively through predetermined dice check outcomes, not through AI-generated structured data. Args: response_text: The raw AI response text Returns: ParsedAIResponse with narrative (game_changes will be empty) """ logger.debug("Parsing AI response", response_length=len(response_text)) # Return the full response as narrative # Game state changes come from predetermined check_outcomes, not AI return ParsedAIResponse( narrative=response_text.strip(), game_changes=GameStateChanges(), raw_response=response_text, parse_success=True, parse_errors=[] ) def _parse_game_actions(data: dict[str, Any]) -> GameStateChanges: """ Parse the game actions dictionary into a GameStateChanges object. Args: data: Dictionary from parsed JSON Returns: GameStateChanges object with parsed data """ changes = GameStateChanges() # Parse items_given if "items_given" in data and data["items_given"]: for item_data in data["items_given"]: if isinstance(item_data, dict): item_grant = ItemGrant( item_id=item_data.get("item_id"), name=item_data.get("name"), item_type=item_data.get("type"), description=item_data.get("description"), value=item_data.get("value", 0), quantity=item_data.get("quantity", 1) ) changes.items_given.append(item_grant) elif isinstance(item_data, str): # Simple string format - treat as item_id changes.items_given.append(ItemGrant(item_id=item_data)) # Parse items_taken if "items_taken" in data and data["items_taken"]: changes.items_taken = [ item_id for item_id in data["items_taken"] if isinstance(item_id, str) ] # Parse gold changes changes.gold_given = int(data.get("gold_given", 0)) changes.gold_taken = int(data.get("gold_taken", 0)) # Parse experience changes.experience_given = int(data.get("experience_given", 0)) # Parse quest changes changes.quest_offered = data.get("quest_offered") changes.quest_completed = data.get("quest_completed") # Parse location change changes.location_change = data.get("location_change") return changes # ============================================================================ # NPC Dialogue Parsing # ============================================================================ @dataclass class ParsedNPCDialogue: """ Parsed NPC dialogue response with quest offer extraction. When NPCs offer quests during conversation, they include a [QUEST_OFFER:quest_id] marker that signals the UI to show a quest accept/decline modal. Attributes: dialogue: The cleaned dialogue text (marker removed) quest_offered: Quest ID if a quest was offered, None otherwise raw_response: The original response text """ dialogue: str quest_offered: Optional[str] = None raw_response: str = "" # Regex pattern for quest offer markers # Matches: [QUEST_OFFER:quest_id] or [QUEST_OFFER: quest_id] QUEST_OFFER_PATTERN = re.compile(r'\[QUEST_OFFER:\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\]') def parse_npc_dialogue(response_text: str) -> ParsedNPCDialogue: """ Parse an NPC dialogue response, extracting quest offer markers. The AI is instructed to include [QUEST_OFFER:quest_id] on its own line when offering a quest. This function extracts the marker and returns the cleaned dialogue. Args: response_text: The raw AI dialogue response Returns: ParsedNPCDialogue with cleaned dialogue and optional quest_offered Example: >>> response = '''*leans in* Got a problem, friend. ... [QUEST_OFFER:quest_cellar_rats] ... Giant rats in me cellar.''' >>> result = parse_npc_dialogue(response) >>> result.quest_offered 'quest_cellar_rats' >>> '[QUEST_OFFER' in result.dialogue False """ logger.debug("Parsing NPC dialogue", response_length=len(response_text)) quest_offered = None dialogue = response_text.strip() # Search for quest offer marker match = QUEST_OFFER_PATTERN.search(dialogue) if match: quest_offered = match.group(1) # Remove the marker from the dialogue dialogue = QUEST_OFFER_PATTERN.sub('', dialogue) # Clean up any extra whitespace/newlines left behind dialogue = re.sub(r'\n\s*\n', '\n\n', dialogue) dialogue = dialogue.strip() logger.info( "Quest offer extracted from dialogue", quest_id=quest_offered, ) return ParsedNPCDialogue( dialogue=dialogue, quest_offered=quest_offered, raw_response=response_text, )