442 lines
16 KiB
Python
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
|