464 lines
21 KiB
Python
464 lines
21 KiB
Python
"""Story setup generation helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Dict, List
|
|
|
|
from fastapi import HTTPException
|
|
from loguru import logger
|
|
|
|
from services.llm_providers.main_text_generation import llm_text_gen
|
|
|
|
from .base import StoryServiceBase
|
|
|
|
|
|
class StorySetupMixin(StoryServiceBase):
|
|
"""Provides story setup generation behaviour."""
|
|
|
|
def generate_premise(
|
|
self,
|
|
*,
|
|
persona: str,
|
|
story_setting: str,
|
|
character_input: str,
|
|
plot_elements: str,
|
|
writing_style: str,
|
|
story_tone: str,
|
|
narrative_pov: str,
|
|
audience_age_group: str,
|
|
content_rating: str,
|
|
ending_preference: str,
|
|
user_id: str,
|
|
) -> str:
|
|
"""Generate a story premise."""
|
|
persona_prompt = self.build_persona_prompt(
|
|
persona,
|
|
story_setting,
|
|
character_input,
|
|
plot_elements,
|
|
writing_style,
|
|
story_tone,
|
|
narrative_pov,
|
|
audience_age_group,
|
|
content_rating,
|
|
ending_preference,
|
|
)
|
|
|
|
parameter_guidance = self._get_parameter_interaction_guidance(
|
|
writing_style, story_tone, audience_age_group, content_rating
|
|
)
|
|
|
|
premise_prompt = f"""\
|
|
{persona_prompt}
|
|
|
|
{parameter_guidance}
|
|
|
|
**TASK: Write a SINGLE, BRIEF premise sentence (1-2 sentences maximum, approximately 20-40 words) for this story.**
|
|
|
|
The premise MUST:
|
|
1. Be written in the specified {writing_style} writing style
|
|
- Interpret and apply this style appropriately for {audience_age_group}
|
|
- Match the language complexity, sentence structure, and narrative approach of this style
|
|
2. Match the {story_tone} story tone exactly
|
|
- Express the emotional atmosphere and mood indicated by this tone
|
|
- Ensure the tone is age-appropriate for {audience_age_group}
|
|
3. Be appropriate for {audience_age_group} with {content_rating} content rating
|
|
- Use language complexity that matches this audience's reading level
|
|
- Use vocabulary that is understandable to this age group
|
|
- Present concepts that are relatable and explainable to this audience
|
|
- Respect the {content_rating} content rating boundaries
|
|
4. Briefly describe the story elements:
|
|
- Setting: {story_setting}
|
|
- Characters: {character_input}
|
|
- Main plot: {plot_elements}
|
|
5. Be clear, engaging, and set up the story without telling the whole story
|
|
6. Be written from the {narrative_pov} point of view
|
|
7. Set up for a {ending_preference} ending
|
|
|
|
**CRITICAL: This is a PREMISE, not the full story.**
|
|
- Keep it to 1-2 sentences maximum (approximately 20-40 words)
|
|
- Do NOT write the full story or multiple paragraphs
|
|
- Do NOT reveal the resolution or ending
|
|
- Focus on the setup: who, where, and what the main challenge/adventure is
|
|
- Use ALL story setup parameters to guide your language and content choices
|
|
- Tailor every word to the target audience ({audience_age_group}) and writing style ({writing_style})
|
|
|
|
Write ONLY the premise sentence(s). Do not write anything else.
|
|
"""
|
|
|
|
try:
|
|
premise = self.generate_with_retry(premise_prompt, user_id=user_id).strip()
|
|
sentences = premise.split(". ")
|
|
if len(sentences) > 2:
|
|
premise = ". ".join(sentences[:2])
|
|
if not premise.endswith("."):
|
|
premise += "."
|
|
return premise
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.error(f"Premise Generation Error: {exc}")
|
|
raise RuntimeError(f"Failed to generate premise: {exc}") from exc
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Setup options
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _build_setup_schema(self) -> Dict[str, Any]:
|
|
"""Return JSON schema for structured setup options."""
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"options": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"persona": {"type": "string"},
|
|
"story_setting": {"type": "string"},
|
|
"character_input": {"type": "string"},
|
|
"plot_elements": {"type": "string"},
|
|
"writing_style": {"type": "string"},
|
|
"story_tone": {"type": "string"},
|
|
"narrative_pov": {"type": "string"},
|
|
"audience_age_group": {"type": "string"},
|
|
"content_rating": {"type": "string"},
|
|
"ending_preference": {"type": "string"},
|
|
"story_length": {"type": "string"},
|
|
"premise": {"type": "string"},
|
|
"reasoning": {"type": "string"},
|
|
},
|
|
"required": [
|
|
"persona",
|
|
"story_setting",
|
|
"character_input",
|
|
"plot_elements",
|
|
"writing_style",
|
|
"story_tone",
|
|
"narrative_pov",
|
|
"audience_age_group",
|
|
"content_rating",
|
|
"ending_preference",
|
|
"story_length",
|
|
"premise",
|
|
"reasoning",
|
|
],
|
|
},
|
|
"minItems": 1,
|
|
"maxItems": 1,
|
|
}
|
|
},
|
|
"required": ["options"],
|
|
}
|
|
|
|
def _build_idea_enhance_schema(self) -> Dict[str, Any]:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"suggestions": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"idea": {"type": "string"},
|
|
"whats_missing": {"type": "string"},
|
|
"why_choose": {"type": "string"},
|
|
},
|
|
"required": ["idea", "whats_missing", "why_choose"],
|
|
},
|
|
"minItems": 3,
|
|
"maxItems": 3,
|
|
}
|
|
},
|
|
"required": ["suggestions"],
|
|
}
|
|
|
|
def generate_story_setup_options(
|
|
self,
|
|
*,
|
|
story_idea: str,
|
|
story_mode: str | None,
|
|
story_template: str | None,
|
|
brand_context: Dict[str, Any] | None,
|
|
user_id: str,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Generate a single story setup option from a user's story idea."""
|
|
|
|
suggested_writing_styles = ['Formal', 'Casual', 'Poetic', 'Humorous', 'Academic', 'Journalistic', 'Narrative']
|
|
suggested_story_tones = ['Dark', 'Uplifting', 'Suspenseful', 'Whimsical', 'Melancholic', 'Mysterious', 'Romantic', 'Adventurous']
|
|
suggested_narrative_povs = ['First Person', 'Third Person Limited', 'Third Person Omniscient']
|
|
suggested_audience_age_groups = ['Children (5-12)', 'Young Adults (13-17)', 'Adults (18+)', 'All Ages']
|
|
suggested_content_ratings = ['G', 'PG', 'PG-13', 'R']
|
|
suggested_ending_preferences = ['Happy', 'Tragic', 'Cliffhanger', 'Twist', 'Open-ended', 'Bittersweet']
|
|
|
|
mode_label = None
|
|
if story_mode == "marketing":
|
|
mode_label = "Non-fiction marketing story (brand or product campaign)"
|
|
elif story_mode == "pure":
|
|
mode_label = "Fiction story"
|
|
|
|
template_label = None
|
|
if story_template == "product_story":
|
|
template_label = "Product Story"
|
|
elif story_template == "brand_manifesto":
|
|
template_label = "Brand Manifesto"
|
|
elif story_template == "founder_story":
|
|
template_label = "Founder Story"
|
|
elif story_template == "customer_story":
|
|
template_label = "Customer Story"
|
|
elif story_template == "short_fiction":
|
|
template_label = "Short Fiction"
|
|
elif story_template == "long_fiction":
|
|
template_label = "Long Fiction"
|
|
elif story_template == "anime_fiction":
|
|
template_label = "Anime Fiction"
|
|
elif story_template == "experimental_fiction":
|
|
template_label = "Experimental Fiction"
|
|
|
|
brand_name = None
|
|
writing_tone = None
|
|
audience_description = None
|
|
if isinstance(brand_context, dict):
|
|
brand_name = brand_context.get("brand_name")
|
|
writing_tone = brand_context.get("writing_tone")
|
|
target_audience = brand_context.get("target_audience")
|
|
if isinstance(target_audience, dict):
|
|
audience_description = target_audience.get("description") or target_audience.get("summary")
|
|
elif isinstance(target_audience, str):
|
|
audience_description = target_audience
|
|
|
|
setup_prompt = f"""\
|
|
You are an expert story writer and creative writing assistant.
|
|
|
|
{"This is a " + mode_label + "." if mode_label else ""}
|
|
{("The user selected the template: " + template_label + ".") if template_label else ""}
|
|
|
|
The story should stay consistent with the brand and audience context below when relevant:
|
|
|
|
- Brand name or site: {brand_name or "Not specified"}
|
|
- Headline/overall writing tone: {writing_tone or "Not specified"}
|
|
- Audience description: {audience_description or "Not specified"}
|
|
|
|
The user has provided the following story idea or information:
|
|
|
|
{story_idea}
|
|
|
|
Based on this story idea, generate exactly 1 well-thought-out story setup option. The setup should be CREATIVE, PERSONALIZED, and perfectly tailored to the user's specific story idea.
|
|
|
|
**CRITICAL - Creative Freedom:**
|
|
- You have COMPLETE FREEDOM to craft personalized values that best fit the user's story idea
|
|
- Do NOT limit yourself to predefined options - create custom, creative values that perfectly match the story concept
|
|
- For example, if the user wants "a story about how stars are made for a 5-year-old", you might create:
|
|
- Writing Style: "Educational Playful" or "Simple Scientific" (not just "Casual" or "Poetic")
|
|
- Story Tone: "Wonder-filled" or "Curious Discovery" (not just "Whimsical" or "Uplifting")
|
|
- Narrative POV: "Second Person (You)" or "Omniscient Narrator as Guide" (not just standard options)
|
|
- The goal is to create the PERFECT setup for THIS specific story, not to fit into generic categories
|
|
|
|
The setup should:
|
|
1. Have a unique and creative persona that fits the story idea perfectly
|
|
2. Define a compelling story setting that brings the idea to life
|
|
3. Describe interesting and engaging characters
|
|
4. Include key plot elements that drive the narrative
|
|
5. Create CUSTOM, PERSONALIZED values for writing style, story tone, narrative POV, audience age group, content rating, and ending preference that best serve the story idea
|
|
6. Select an appropriate story length: "Short (>1000 words)" for brief stories, "Medium (>5000 words)" for standard-length stories, or "Long (>10000 words)" for extended, detailed stories
|
|
7. Generate a brief story premise (1-2 sentences, approximately 20-40 words) that summarizes the story concept
|
|
8. Provide a brief reasoning (2-3 sentences) explaining why this setup works well for the story idea
|
|
|
|
**IMPORTANT - Premise Requirements:**
|
|
- The premise MUST be age-appropriate for the selected audience_age_group
|
|
- For Children (5-12): Use simple, everyday words. Avoid complex vocabulary like "nebular", "ionized", "cosmic", "stellar", "melancholic", "bittersweet"
|
|
- The premise MUST match the selected writing_style (e.g., if custom style is "Educational Playful", use playful educational language)
|
|
- The premise MUST match the selected story_tone (e.g., if custom tone is "Wonder-filled", create a sense of wonder)
|
|
- Keep the premise to 1-2 sentences maximum
|
|
- Focus on who, where, and what the main challenge/adventure is
|
|
|
|
**Suggested Options (for reference only - feel free to create better custom values):**
|
|
- Writing Styles (suggestions): {', '.join(suggested_writing_styles)}
|
|
- Story Tones (suggestions): {', '.join(suggested_story_tones)}
|
|
- Narrative POVs (suggestions): {', '.join(suggested_narrative_povs)}
|
|
- Audience Age Groups (suggestions): {', '.join(suggested_audience_age_groups)}
|
|
- Content Ratings (suggestions): {', '.join(suggested_content_ratings)}
|
|
- Ending Preferences (suggestions): {', '.join(suggested_ending_preferences)}
|
|
- Story Lengths: "Short (>1000 words)", "Medium (>5000 words)", "Long (>10000 words)"
|
|
|
|
**Remember:** These are ONLY suggestions. If a custom value better serves the story idea, CREATE IT!
|
|
|
|
Return exactly 1 option as a JSON array with a single object in "options". The object must include a "premise" field with the story premise.
|
|
"""
|
|
|
|
setup_schema = self._build_setup_schema()
|
|
|
|
try:
|
|
logger.info(f"[StoryWriter] Generating story setup option for user {user_id}")
|
|
response = self.load_json_response(
|
|
llm_text_gen(prompt=setup_prompt, json_struct=setup_schema, user_id=user_id)
|
|
)
|
|
|
|
options = response.get("options", [])
|
|
if len(options) != 1:
|
|
logger.warning(f"[StoryWriter] Expected 1 option but got {len(options)}, correcting count")
|
|
if len(options) < 1:
|
|
raise ValueError(f"Expected 1 option but got {len(options)}")
|
|
options = options[:1]
|
|
|
|
for idx, option in enumerate(options):
|
|
if not option.get("premise") or not option.get("premise", "").strip():
|
|
logger.info(f"[StoryWriter] Generating premise for option {idx + 1}")
|
|
try:
|
|
option["premise"] = self.generate_premise(
|
|
persona=option.get("persona", ""),
|
|
story_setting=option.get("story_setting", ""),
|
|
character_input=option.get("character_input", ""),
|
|
plot_elements=option.get("plot_elements", ""),
|
|
writing_style=option.get("writing_style", "Narrative"),
|
|
story_tone=option.get("story_tone", "Adventurous"),
|
|
narrative_pov=option.get("narrative_pov", "Third Person Limited"),
|
|
audience_age_group=option.get("audience_age_group", "All Ages"),
|
|
content_rating=option.get("content_rating", "G"),
|
|
ending_preference=option.get("ending_preference", "Happy"),
|
|
user_id=user_id,
|
|
)
|
|
except Exception as exc: # pragma: no cover - fallback clause
|
|
logger.warning(f"[StoryWriter] Failed to generate premise for option {idx + 1}: {exc}")
|
|
option["premise"] = (
|
|
f"A {option.get('story_setting', 'story')} story featuring "
|
|
f"{option.get('character_input', 'characters')}."
|
|
)
|
|
else:
|
|
premise = option["premise"].strip()
|
|
sentences = premise.split(". ")
|
|
if len(sentences) > 2:
|
|
premise = ". ".join(sentences[:2])
|
|
if not premise.endswith("."):
|
|
premise += "."
|
|
option["premise"] = premise
|
|
|
|
logger.info(f"[StoryWriter] Generated {len(options)} story setup option(s) with premise for user {user_id}")
|
|
return options
|
|
except HTTPException:
|
|
raise
|
|
except json.JSONDecodeError as exc:
|
|
logger.error(f"[StoryWriter] Failed to parse JSON response for story setup: {exc}")
|
|
raise RuntimeError(f"Failed to parse story setup options: {exc}") from exc
|
|
except Exception as exc:
|
|
logger.error(f"[StoryWriter] Error generating story setup options: {exc}")
|
|
raise RuntimeError(f"Failed to generate story setup options: {exc}") from exc
|
|
|
|
def enhance_story_idea(
|
|
self,
|
|
*,
|
|
story_idea: str,
|
|
story_mode: str | None,
|
|
story_template: str | None,
|
|
brand_context: Dict[str, Any] | None,
|
|
user_id: str,
|
|
fiction_variant: str | None = None,
|
|
narrative_energy: str | None = None,
|
|
) -> List[Dict[str, Any]]:
|
|
mode_label = None
|
|
if story_mode == "marketing":
|
|
mode_label = "Non-fiction marketing story (brand or product campaign)"
|
|
elif story_mode == "pure":
|
|
mode_label = "Fiction story"
|
|
|
|
template_label = None
|
|
if story_template == "product_story":
|
|
template_label = "Product Story"
|
|
elif story_template == "brand_manifesto":
|
|
template_label = "Brand Manifesto"
|
|
elif story_template == "founder_story":
|
|
template_label = "Founder Story"
|
|
elif story_template == "customer_story":
|
|
template_label = "Customer Story"
|
|
elif story_template == "short_fiction":
|
|
template_label = "Short Fiction"
|
|
elif story_template == "long_fiction":
|
|
template_label = "Long Fiction"
|
|
elif story_template == "anime_fiction":
|
|
template_label = "Anime Fiction"
|
|
elif story_template == "experimental_fiction":
|
|
template_label = "Experimental Fiction"
|
|
|
|
brand_name = None
|
|
writing_tone = None
|
|
audience_description = None
|
|
if isinstance(brand_context, dict):
|
|
brand_name = brand_context.get("brand_name")
|
|
writing_tone = brand_context.get("writing_tone")
|
|
target_audience = brand_context.get("target_audience")
|
|
if isinstance(target_audience, dict):
|
|
audience_description = target_audience.get("description") or target_audience.get("summary")
|
|
elif isinstance(target_audience, str):
|
|
audience_description = target_audience
|
|
|
|
fiction_focus_line = ""
|
|
if fiction_variant:
|
|
fiction_focus_line = f'Treat the story as "{fiction_variant}" and lean into that creative focus.'
|
|
|
|
energy_line = ""
|
|
if narrative_energy:
|
|
energy_line = f'Target narrative energy: {narrative_energy}.'
|
|
|
|
enhance_prompt = f"""You are a creative writing coach helping a user refine and expand a story idea.
|
|
|
|
{"This is a " + mode_label + "." if mode_label else ""}
|
|
{("The user selected the template: " + template_label + ".") if template_label else ""}
|
|
{fiction_focus_line}
|
|
{energy_line}
|
|
|
|
When relevant, keep the idea aligned with this brand and audience context:
|
|
- Brand name or site: {brand_name or "Not specified"}
|
|
- Headline/overall writing tone: {writing_tone or "Not specified"}
|
|
- Audience description: {audience_description or "Not specified"}
|
|
|
|
The user has written the following story idea or concept:
|
|
|
|
{story_idea}
|
|
|
|
Your task is to propose exactly 3 alternative enhanced story idea options.
|
|
|
|
Each option must:
|
|
- Preserve the user's core premise and intent.
|
|
- Make the premise clearer and more compelling.
|
|
- Surface the central conflict or tension.
|
|
- Clarify the main characters and their goals.
|
|
- Strengthen the setting and stakes.
|
|
- Stay at the "idea" level, not a full outline or beat-by-beat breakdown.
|
|
|
|
For each option, return three fields:
|
|
- "idea": 2-4 sentences describing the improved story idea, suitable for a single textarea input.
|
|
- "whats_missing": 2-4 sentences explaining what important details are missing or underspecified in the current brief. Focus on gaps such as: protagonist details, antagonist or opposing force, stakes, setting and time period, audience/age group, subgenre or type of fiction (for example, anime vs grounded sci-fi), language or tone preferences, and any format constraints.
|
|
- "why_choose": 1-3 sentences explaining how this option interprets the original idea and why it might be a strong direction for the story.
|
|
|
|
Do not write a full story outline.
|
|
Do not output numbered lists or markdown formatting.
|
|
|
|
Return a single JSON object with a "suggestions" array of 3 items, where each item has the keys "idea", "whats_missing", and "why_choose"."""
|
|
|
|
schema = self._build_idea_enhance_schema()
|
|
|
|
try:
|
|
logger.info(f"[StoryWriter] Enhancing story idea with structured suggestions for user {user_id}")
|
|
response = self.load_json_response(
|
|
llm_text_gen(prompt=enhance_prompt, json_struct=schema, user_id=user_id)
|
|
)
|
|
suggestions = response.get("suggestions", [])
|
|
if len(suggestions) != 3:
|
|
logger.warning(
|
|
f"[StoryWriter] Expected 3 idea suggestions but got {len(suggestions)}, correcting count"
|
|
)
|
|
if len(suggestions) < 3:
|
|
raise ValueError(f"Expected 3 suggestions but got {len(suggestions)}")
|
|
suggestions = suggestions[:3]
|
|
return suggestions
|
|
except HTTPException:
|
|
raise
|
|
except json.JSONDecodeError as exc:
|
|
logger.error(f"[StoryWriter] Failed to parse JSON response for story idea enhancement: {exc}")
|
|
raise RuntimeError(f"Failed to parse story idea enhancement suggestions: {exc}") from exc
|
|
except Exception as exc:
|
|
logger.error(f"[StoryWriter] Error enhancing story idea: {exc}")
|
|
raise RuntimeError(f"Failed to enhance story idea: {exc}") from exc
|
|
|