Files
Code_of_Conquest/api/app/ai/response_parser.py
Phillip Tarrant df26abd207 feat: Implement Phase 5 Quest System (100% complete)
Add YAML-driven quest system with context-aware offering:

Core Implementation:
- Quest data models (Quest, QuestObjective, QuestReward, QuestTriggers)
- QuestService for YAML loading and caching
- QuestEligibilityService with level, location, and probability filtering
- LoreService stub (MockLoreService) ready for Phase 6 Weaviate integration

Quest Content:
- 5 example quests across difficulty tiers (2 easy, 2 medium, 1 hard)
- Quest-centric design: quests define their NPC givers
- Location-based probability weights for natural quest offering

AI Integration:
- Quest offering section in npc_dialogue.j2 template
- Response parser extracts [QUEST_OFFER:quest_id] markers
- AI naturally weaves quest offers into NPC conversations

API Endpoints:
- POST /api/v1/quests/accept - Accept quest offer
- POST /api/v1/quests/decline - Decline quest offer
- POST /api/v1/quests/progress - Update objective progress
- POST /api/v1/quests/complete - Complete quest, claim rewards
- POST /api/v1/quests/abandon - Abandon active quest
- GET /api/v1/characters/{id}/quests - List character quests
- GET /api/v1/quests/{quest_id} - Get quest details

Frontend:
- Quest tracker sidebar with HTMX integration
- Quest offer modal for accept/decline flow
- Quest detail modal for viewing progress
- Combat service integration for kill objective tracking

Testing:
- Unit tests for Quest models and serialization
- Integration tests for full quest lifecycle
- Comprehensive test coverage for eligibility service

Documentation:
- Reorganized docs into /docs/phases/ structure
- Added Phase 5-12 planning documents
- Updated ROADMAP.md with new structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 15:42:55 -06:00

244 lines
7.6 KiB
Python

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