lotr-sut/sut/backend/services/quest_generation_service.py
Fellowship Scholar f6a5823439 init commit
2026-03-29 20:07:56 +00:00

442 lines
16 KiB
Python

"""Quest generation service for NPC-driven quest creation.
Generates semantically coherent, character-appropriate quests based on:
- NPC character personality
- Conversation context
- LOTR theme adherence
"""
import json
import logging
import random
import re
from typing import Any, Dict, List, Optional, Tuple
from flask import current_app
from openai import AzureOpenAI
from models.location import Location
from services.character_profiles import get_character_profile, get_quest_affinity
# Configure logging
logger = logging.getLogger(__name__)
# Quest generation templates organized by NPC
QUEST_GENERATION_TEMPLATES: Dict[str, Dict[str, Any]] = {
"frodo": {
"contexts": [
"The user mentions a problem or burden they're carrying.",
"The user asks for guidance on what to do next.",
"The user seems overwhelmed by many tasks.",
],
"themes": [
"Hidden paths and solutions",
"Understanding the true nature of a problem",
"Journeys to unfamiliar places",
"Tests of character and courage",
],
"title_seeds": [
"Uncover the {nature} of {subject}",
"Journey to {location} and discover {objective}",
"Face your doubt about {challenge}",
"Find the hidden wisdom in {situation}",
],
"types": ["The Journey", "The Fellowship", "The Ring"],
"priorities": ["Important", "Critical"],
},
"sam": {
"contexts": [
"The user has completed something and needs momentum.",
"The user asks for practical help or advice.",
"The user seems stuck and needs encouragement.",
],
"themes": [
"Building and creating",
"Practical problem-solving",
"Loyalty and companionship",
"Caring for others or a place",
],
"title_seeds": [
"Prepare {place} for {purpose}",
"Build or fix {object} for {reason}",
"Gather supplies: {list}",
"Care for {person_or_place} by {action}",
],
"types": ["The Fellowship", "The Battle", "The Journey"],
"priorities": ["Important", "Standard"],
},
"gandalf": {
"contexts": [
"The user has reached a critical decision point.",
"The user is avoiding an important choice.",
"The user asks for strategic guidance.",
],
"themes": [
"Strategic choices with large consequences",
"Testing someone's resolve or wisdom",
"Understanding larger patterns",
"Containing or confronting darkness",
],
"title_seeds": [
"Decide the fate of {stakes}",
"Confront {threat} before it spreads",
"Understand the pattern of {mystery}",
"Test your resolve: {challenge}",
],
"types": ["The Ring", "Dark Magic", "The Battle"],
"priorities": ["Critical", "Important"],
},
}
# Fallback quest generation (no AI)
FALLBACK_QUESTS: Dict[str, List[Dict[str, Any]]] = {
"frodo": [
{
"title": "Discover the Heart of the Matter",
"description": "Consider this problem deeply: what lies at its true center? It may appear different when you understand its nature.",
"quest_type": "The Journey",
"priority": "Important",
},
{
"title": "Walk the Hidden Path",
"description": "Every great challenge has an unexpected approach. Take time to find the unconventional route forward.",
"quest_type": "The Fellowship",
"priority": "Important",
},
{
"title": "Test Your Courage",
"description": "Sometimes the next step demands we face what we've been avoiding. What fear guards your path?",
"quest_type": "The Ring",
"priority": "Critical",
},
],
"sam": [
{
"title": "Prepare the Ground",
"description": "Good work starts with preparation. Gather what you need and organize it well before beginning.",
"quest_type": "The Fellowship",
"priority": "Important",
},
{
"title": "Strengthen Your Bonds",
"description": "Reach out and help a companion with something they're struggling with. Loyalty matters.",
"quest_type": "The Fellowship",
"priority": "Standard",
},
{
"title": "Build Something That Lasts",
"description": "Create or improve something that will help you and others in the times ahead.",
"quest_type": "The Battle",
"priority": "Important",
},
],
"gandalf": [
{
"title": "Recognize the Pattern",
"description": "Step back and observe the larger picture. What do the recent events tell you about the true state of affairs?",
"quest_type": "The Ring",
"priority": "Critical",
},
{
"title": "Make the Hard Choice",
"description": "A decision looms that cannot be avoided. Choose based on principle, not comfort.",
"quest_type": "The Ring",
"priority": "Critical",
},
{
"title": "Confront the Advancing Shadow",
"description": "A threat grows. Take action now before it becomes unstoppable.",
"quest_type": "Dark Magic",
"priority": "Critical",
},
],
}
# Middle-earth locations mapping (case-insensitive)
MIDDLE_EARTH_LOCATIONS: Dict[str, List[str]] = {
"Rivendell": ["rivendell", "elrond's home", "valley of imladris", "imladris"],
"Lothlórien": ["lothlórien", "lothlórien", "golden wood", "caras galadhon"],
"Moria": ["moria", "khazad-dum", "dwarf kingdom", "mines of moria"],
"Mordor": ["mordor", "sauron's realm", "mount doom", "barad-dûr"],
"Rohan": ["rohan", "rolling plains", "mark", "edoras"],
"Gondor": ["gondor", "minas tirith", "white city", "kingdom of men"],
"The Shire": ["the shire", "shire", "hobbiton", "bag end"],
"Isengard": ["isengard", "orthanc", "wizard's tower"],
"Mirkwood": ["mirkwood", "greenwood", "thranduil", "wood-elves"],
"Lake-town": ["lake-town", "esgaroth", "bard", "barrel rider"],
"The Grey Havens": ["grey havens", "grey havens", "valinor", "undying lands", "sailing west"],
"Erebor": ["erebor", "lonely mountain", "dwarf kingdom"],
"The Grey Mountains": ["grey mountains", "misty mountains", "mountains"],
}
def _find_location_by_text(text: str) -> Optional[Tuple[str, int]]:
"""Extract and find a location from text.
Searches through MIDDLE_EARTH_LOCATIONS and the database to find mentions.
Returns the Location name and ID that was mentioned in the text.
Args:
text: Text to search for location mentions
Returns:
Tuple of (location_name, location_id) or None
"""
if not text:
return None
text_lower = text.lower()
# Search by known aliases
for location_name, aliases in MIDDLE_EARTH_LOCATIONS.items():
for alias in aliases:
if alias in text_lower:
# Try to find this location in database
try:
location = Location.query.filter_by(name=location_name).first()
if location:
return (location_name, location.id)
except Exception as e:
logger.warning(f"Failed to query location {location_name}: {e}")
return (location_name, None)
return None
def _add_location_to_quest(quest: Dict[str, Any]) -> Dict[str, Any]:
"""Add location_id to a quest based on location mention in description.
Searches the quest description for Middle-earth location mentions
and adds location_id if found.
Args:
quest: Quest dict with title, description, quest_type, priority
Returns:
Same quest dict with optional location_id added
"""
if not quest.get("description"):
return quest
location_result = _find_location_by_text(quest["description"])
if location_result:
location_name, location_id = location_result
if location_id:
quest["location_id"] = location_id
logger.debug(f"✓ Assigned location '{location_name}' (ID: {location_id}) to quest")
return quest
def _new_azure_client() -> Optional[AzureOpenAI]:
"""Create Azure OpenAI client if configured."""
endpoint = current_app.config.get("AZURE_OPENAI_ENDPOINT", "")
api_key = current_app.config.get("AZURE_OPENAI_API_KEY", "")
api_version = current_app.config.get("AZURE_OPENAI_API_VERSION", "2024-02-15-preview")
if not endpoint or not api_key:
return None
return AzureOpenAI(
azure_endpoint=endpoint,
api_key=api_key,
api_version=api_version,
)
def _generate_quest_with_ai(
character: str,
user_message: str,
conversation_history: List[Dict[str, str]],
) -> Optional[Dict[str, Any]]:
"""Generate a quest using Azure OpenAI.
Args:
character: NPC character (frodo, sam, gandalf)
user_message: User's latest message
conversation_history: Recent conversation turns
Returns:
Generated quest dict or None if AI fails
"""
deployment = current_app.config.get("AZURE_OPENAI_DEPLOYMENT", "")
if not deployment:
return None
client = _new_azure_client()
if client is None:
return None
profile = get_character_profile(character)
quest_types = get_quest_affinity(character)
# Build system prompt for quest generation with better context awareness
character_context = ""
if character == "frodo":
character_context = (
"Frodo speaks of quests related to the Ring, Mordor, journeys, and bearing burdens. "
"He frames activities as part of a larger quest toward freedom. "
"Suggest locations like 'Rivendell', 'Lothlórien', 'Moria', or 'Mordor'. "
)
elif character == "sam":
character_context = (
"Sam thinks in practical terms: building, preparing, defending, growing. "
"He frames quests around making things better and stronger. "
"Suggest locations like 'The Shire', 'Gondor', or 'The Grey Havens'. "
)
elif character == "gandalf":
character_context = (
"Gandalf sees the bigger strategic picture and long-term consequences. "
"He frames quests as moves in a grand strategy against darkness. "
"Suggest locations like 'Isengard', 'Orthanc', 'Moria', or 'The Grey Havens'. "
)
system_prompt = f"""You are {profile.get('full_name')}, {profile.get('title')}.
{character_context}
Your job: Create a quest that:
1. Directly ties to what the user just said in conversation
2. Feels authentic to {character}'s personality and way of thinking
3. Uses one of these quest types: {", ".join(quest_types)}
4. Is achievable yet substantial and meaningful
5. Is set in Middle-earth—suggest a specific location
6. Frames it in {character}'s voice and perspective
Respond with ONLY valid JSON (no markdown, no explanation):
{{
"title": "Quest name (4-8 words, action-oriented)",
"description": "2-3 sentences: (a) what the quest entails, (b) why it matters, (c) which location it involves",
"quest_type": "{quest_types[0]}",
"priority": "Important"
}}"""
# Build conversation context with better preservation of dialogue flow
messages = [{"role": "system", "content": system_prompt}]
# Include last few turns to understand context
for turn in conversation_history[-6:]:
messages.append({"role": turn["role"], "content": turn["content"]})
# Ask for quest based on the actual conversation, not just latest message
context_summary = f"""Based on what I just heard, here's what stands out as a quest opportunity:
Latest from the user: "{user_message}"
Now, {profile.get('full_name')}, what quest would help them move forward?"""
messages.append({"role": "user", "content": context_summary})
try:
completion = client.chat.completions.create(
model=deployment,
messages=messages,
max_tokens=300,
temperature=0.8,
)
response_text = (completion.choices[0].message.content or "").strip()
# Try to parse JSON
quest_data = json.loads(response_text)
# Validate required fields
if all(k in quest_data for k in ["title", "description", "quest_type", "priority"]):
# Add location to quest if mentioned in description
quest_data = _add_location_to_quest(quest_data)
logger.info(f"✓ Azure OpenAI generated quest for {character}")
return quest_data
except Exception as e:
logger.error(f"✗ Quest generation failed for {character}: {type(e).__name__}: {str(e)}")
pass
return None
def generate_quest(
character: str,
user_message: str,
conversation_history: Optional[List[Dict[str, str]]] = None,
) -> Optional[Dict[str, Any]]:
"""Generate a quest appropriate to the NPC character.
Uses AI if available, falls back to templates + randomization.
Always attempts to assign a location based on quest description.
Args:
character: NPC character (frodo, sam, gandalf)
user_message: User's latest message
conversation_history: Recent conversation turns (optional)
Returns:
Quest dict with title, description, quest_type, priority, and optional location_id
"""
if not conversation_history:
conversation_history = []
# Try AI generation first
ai_quest = _generate_quest_with_ai(character, user_message, conversation_history)
if ai_quest:
return ai_quest
# Fall back to template-based generation
if character not in FALLBACK_QUESTS:
character = "gandalf"
quest = random.choice(FALLBACK_QUESTS[character])
# Add location to fallback quest too
quest = _add_location_to_quest(quest)
return quest
def should_offer_quest(user_message: str, conversation_turn_count: int = 0) -> bool:
"""Determine if this is a good moment to offer a quest.
Offers quests when:
- User seems to be looking for direction or action (keywords)
- Early in conversation (turn 1-2) to set the tone
- User asks for help explicitly
- User seems stuck or overwhelmed
Args:
user_message: User's latest message
conversation_turn_count: Number of turns in conversation
Returns:
True if a quest should be offered
"""
message_lower = user_message.lower().strip()
# Strong signals for quest readiness
strong_keywords = [
"help", "stuck", "what next", "what should", "can you suggest",
"guide", "quest", "task", "challenge", "adventure", "action",
"ready", "let's", "should we", "next step", "forward"
]
has_strong_signal = any(kw in message_lower for kw in strong_keywords)
# Softer signals (still valid but need turn count context)
soft_keywords = ["do", "can", "shall", "would", "could", "problem"]
has_soft_signal = any(kw in message_lower for kw in soft_keywords)
# Never offer in the first turn (let conversation start naturally)
if conversation_turn_count < 1:
return False
# Always offer if there's a strong signal
if has_strong_signal:
return True
# Offer in early conversations even with soft signals
if conversation_turn_count <= 2 and has_soft_signal:
return True
# Occasionally offer even without keywords (keep engagement)
if conversation_turn_count == 2 and len(message_lower) > 10:
return True
return False