Files
ALwrity/ToBeMigrated/ai_writers/ai_letter_writer/personal_letter.py
2025-08-06 16:29:49 +05:30

1122 lines
58 KiB
Python

"""
Personal Letters Module
This module provides a Streamlit interface for generating various types of personal letters
using AI assistance. It collects user inputs specific to the chosen personal letter subtype,
formats the data, generates a prompt for the AI, calls the AI for content generation,
and displays the formatted letter preview and analysis.
"""
import streamlit as st
import datetime
from typing import Dict, Any, List
# Assuming these modules and functions exist and are correctly imported in a real application.
# Placeholder functions are included below for demonstration purposes if actual imports are not available.
# from ..utils.letter_formatter import format_letter, get_letter_preview_html
# from ..utils.letter_analyzer import analyze_letter_tone, check_formality, get_readability_metrics, suggest_improvements
# from ..utils.letter_templates import get_template_by_type
# from ....gpt_providers.text_generation.main_text_generation import llm_text_gen
# --- Placeholder Functions (Replace with actual imports in a real app) ---
# These placeholders mimic the expected behavior of the imported functions
# to allow the rest of the code structure to be reviewed and run without dependencies.
def format_letter(content: str, metadata: Dict[str, Any], letter_type: str = "personal") -> str:
"""Placeholder: Returns the content as is."""
return content
def get_letter_preview_html(content: str, metadata: Dict[str, Any], letter_type: str = "personal") -> str:
"""Placeholder: Generates a basic HTML preview for personal letters."""
# Basic HTML structure with inline styles for a personal letter feel
formatted_paragraphs = "".join(f"<p style='margin-bottom: 1em;'>{p.strip()}</p>" for p in content.split("\n\n") if p.strip())
return f"""
<div style="max-width: 700px; margin: 20px auto; padding: 30px; border: 1px solid #e0e0e0; border-radius: 8px; background-color: #ffffff; font-family: 'Georgia', serif; line-height: 1.7; color: #333;">
<div style="text-align: right; margin-bottom: 30px; font-size: 0.9em; color: #555;">
{metadata.get('date', 'Date')}
</div>
<div style="margin-bottom: 30px;">
{formatted_paragraphs if formatted_paragraphs else "<p>Letter content goes here...</p>"}
</div>
<div style="margin-top: 40px;">
<p style="margin-bottom: 0.5em;">{metadata.get('complimentary_close', 'Sincerely,')}</p>
<p style="font-weight: bold; margin-top: 0;">{metadata.get('sender_name', 'Sender Name')}</p>
</div>
</div>
"""
def analyze_letter_tone(content: str) -> Dict[str, float]:
"""Placeholder: Returns dummy tone analysis."""
# Returns scores between 0.0 and 1.0
return {"warm": 0.9, "sincere": 0.8, "friendly": 0.7}
def check_formality(content: str) -> float:
"""Placeholder: Returns a dummy formality score (0.0 to 1.0)."""
# Personal letters are typically less formal
return 0.30 # Example: 30% formal
def get_readability_metrics(content: str) -> Dict[str, Any]:
"""Placeholder: Returns dummy readability metrics."""
word_count = len(content.split())
# Estimate reading time in seconds (assuming ~200 words per minute)
reading_time_seconds = round((word_count / 200) * 60)
return {
"word_count": word_count,
"sentence_count": max(1, content.count('. ') + content.count('! ') + content.count('? ')), # Simple sentence count
"avg_words_per_sentence": round(word_count / max(1, content.count('. ') + content.count('! ') + content.count('? ')), 2),
"flesch_reading_ease": 70.0, # Dummy score for personal letters
"reading_level": "Easy", # Dummy level
"reading_time_seconds": reading_time_seconds # Added reading time
}
def suggest_improvements(content: str, letter_type: str) -> List[str]:
"""Placeholder: Returns dummy improvement suggestions."""
if len(content) < 100:
return ["Suggestion: The letter seems very brief. Consider adding more personal details or anecdotes."]
elif "generic" in content.lower():
return ["Suggestion: Try to make the language more specific and personal to your relationship."]
else:
return ["Suggestion: Read it aloud to check if it sounds like your natural voice."]
def get_template_by_type(letter_type: str, subtype: str = "default") -> Dict[str, Any]:
"""Placeholder: Returns a generic template."""
# This should ideally come from the actual letter_templates module
return {"structure": ["Greeting", "Body", "Closing"], "guidance": "Generic guidance for a personal letter."}
def llm_text_gen(prompt: str) -> str:
"""Placeholder: Simulates LLM text generation."""
# In a real app, this would call the actual LLM API
st.info(f"LLM Prompt:\n```\n{prompt}\n```") # Display prompt for debugging
# Return a dummy generated letter based on the prompt
return f"Hi [Generated Recipient Name],\n\nThis is a sample personal letter generated based on the following details:\n\n{prompt}\n\n[Generated content based on the prompt would go here, following the requested structure, tone, emotion, and style.]\n\nBest,\n[Generated Your Name]"
# --- End Placeholder Functions ---
def write_letter():
"""
Main function for the Personal Letters interface. Sets up the Streamlit page
and handles navigation between subtype selection and the letter form.
"""
# Page title and description
st.title("💌 Personal Letter Writer")
st.markdown("""
Create heartfelt personal letters for friends, family, and loved ones. Select a letter type below to get started.
""")
# Initialize Streamlit session state variables specific to the personal module.
# These variables persist across reruns and store the user's progress and data.
if "personal_letter_subtype" not in st.session_state:
st.session_state.personal_letter_subtype = None # Stores the ID of the selected personal letter type
if "personal_letter_generated" not in st.session_state:
st.session_state.personal_letter_generated = False # Flag to indicate if a letter has been generated
if "personal_letter_content" not in st.session_state:
st.session_state.personal_letter_content = None # Stores the generated letter content
if "personal_letter_metadata" not in st.session_state:
st.session_state.personal_letter_metadata = {} # Stores metadata like sender/recipient info
if "personal_letter_form_data" not in st.session_state:
st.session_state.personal_letter_form_data = {} # Stores the user's input from the form fields
# Back button logic for subtypes. This button appears when a subtype is selected,
# allowing the user to return to the subtype selection screen.
if st.session_state.personal_letter_subtype is not None:
if st.button("← Back to Personal Letter Types"):
# Reset session state variables for this module to their initial state
# This clears the current form data and generated letter.
st.session_state.personal_letter_subtype = None
st.session_state.personal_letter_generated = False
st.session_state.personal_letter_content = None
st.session_state.personal_letter_metadata = {}
st.session_state.personal_letter_form_data = {}
st.rerun() # Rerun the app to update the UI based on the changed state
# Main navigation logic within the personal module.
# If no subtype is selected, show the selection grid. Otherwise, show the form for the selected subtype.
if st.session_state.personal_letter_subtype is None:
# Display personal letter type selection if no subtype is selected
display_personal_letter_types()
else:
# Display the interface form for the selected personal letter subtype
display_personal_letter_form(st.session_state.personal_letter_subtype)
def display_personal_letter_types():
"""
Displays the personal letter type selection interface using a grid of styled buttons.
Each button represents a specific type of personal letter the user can choose to write.
"""
st.markdown("## Select Personal Letter Type")
# Define personal letter types with their details (ID, Name, Icon, Description, Color)
# This list is used to generate the selection buttons.
personal_letter_types = [
{
"id": "congratulations",
"name": "Congratulations",
"icon": "🎉",
"description": "Celebrate achievements and milestones",
"color": "#43A047" # Green
},
{
"id": "thank_you",
"name": "Thank You",
"icon": "🙏",
"description": "Express gratitude for gifts, help, or support",
"color": "#1E88E5" # Blue
},
{
"id": "sympathy",
"name": "Sympathy",
"icon": "💐",
"description": "Offer comfort during difficult times",
"color": "#5E35B1" # Deep Purple
},
{
"id": "apology",
"name": "Apology",
"icon": "🙇",
"description": "Say sorry and make amends",
"color": "#FB8C00" # Orange
},
{
"id": "invitation",
"name": "Invitation",
"icon": "✉️",
"description": "Invite someone to an event or gathering",
"color": "#EC407A" # Pink
},
{
"id": "friendship",
"name": "Friendship",
"icon": "👫",
"description": "Nurture and celebrate friendships",
"color": "#00ACC1" # Cyan
},
{
"id": "love",
"name": "Love Letter",
"icon": "❤️",
"description": "Express romantic feelings and affection",
"color": "#E53935" # Red
},
{
"id": "encouragement",
"name": "Encouragement",
"icon": "🌟",
"description": "Offer support and motivation",
"color": "#FFB300" # Amber
},
{
"id": "farewell",
"name": "Farewell",
"icon": "👋",
"description": "Say goodbye to friends or colleagues",
"color": "#8E24AA" # Purple
}
]
# Inject custom CSS to style the Streamlit buttons to look like cards.
# This provides a visually appealing selection grid.
st.markdown("""
<style>
/* Target Streamlit buttons and apply card-like styling */
div.stButton > button {
width: 100%; /* Make buttons fill their column */
height: 200px; /* Fixed height for consistent grid */
border-radius: 10px;
padding: 20px;
margin-bottom: 15px; /* Space between rows */
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
font-size: 16px;
border: none;
cursor: pointer;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
color: white !important; /* Ensure text is white */
}
/* Hover effect */
div.stButton > button:hover {
transform: translateY(-5px); /* Lift effect */
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
/* Style for the text inside the button */
div.stButton > button h3 {
color: white !important; /* Ensure icon/title is white */
margin-top: 0;
margin-bottom: 10px;
font-size: 1.2em; /* Adjust title size */
}
div.stButton > button p {
color: white !important; /* Ensure description is white */
font-size: 0.9em; /* Adjust description size */
margin: 0;
}
</style>
""", unsafe_allow_html=True)
# Create a grid layout for the buttons using Streamlit columns (3 columns per row).
cols = st.columns(3)
# Display each letter type as a button.
for i, letter_type_config in enumerate(personal_letter_types):
with cols[i % 3]: # Place buttons in columns, wrapping every 3
# Use a unique key for each button based on its ID
# The button label uses markdown and HTML for icon, name, and description
if st.button(
f"### {letter_type_config['icon']} {letter_type_config['name']}\n\n<p>{letter_type_config['description']}</p>",
key=f"btn_personal_select_{letter_type_config['id']}", # Unique key for each button
unsafe_allow_html=True # Allow markdown and HTML in the button label
):
# When a button is clicked, update the session state to the selected subtype ID
st.session_state.personal_letter_subtype = letter_type_config['id']
# Clear previous data related to letter generation when selecting a new type
st.session_state.personal_letter_generated = False
st.session_state.personal_letter_content = None
st.session_state.personal_letter_metadata = {}
st.session_state.personal_letter_form_data = {} # Clear previous form data
st.rerun() # Rerun the app to switch to the form for the selected subtype
# Apply specific background colors to buttons using their keys and custom CSS
# This requires injecting CSS after the buttons are rendered.
# Note: This is a common Streamlit workaround for styling individual buttons dynamically.
button_styles = ""
for letter_type_config in personal_letter_types:
button_styles += f"""
div.stButton > button[data-testid="stButton"][kind="primary"][sf-key*="btn_personal_select_{letter_type_config['id']}"] {{
background-color: {letter_type_config['color']};
}}
div.stButton > button[data-testid="stButton"][kind="primary"][sf-key*="btn_personal_select_{letter_type_config['id']}"]:hover {{
background-color: {letter_type_config['color']}D9; /* Slightly darker on hover */
}}
"""
st.markdown(f"<style>{button_styles}</style>", unsafe_allow_html=True)
def display_personal_letter_form(subtype: str):
"""
Displays the form for the selected personal letter subtype. This includes
input fields specific to the subtype, personal information fields,
tone and style options, and tabs for previewing and analyzing the generated letter.
Args:
subtype: The ID string of the selected personal letter subtype.
"""
# Get the template for the selected subtype from the templates module.
# This provides structural guidance and general advice for the LLM.
template = get_template_by_type("personal", subtype)
# Display the form title, icon, description, and guidance.
st.markdown(f"## {get_icon_for_subtype(subtype)} {get_name_for_subtype(subtype)}")
st.markdown(f"*{get_description_for_subtype(subtype)}*")
st.info(f"**Guidance:** {template.get('guidance', 'No specific guidance available.')}")
# Use a Streamlit form to group inputs. This helps manage state and
# prevents the app from rerunning every time a single input widget changes,
# improving performance for forms with many inputs.
with st.form(key=f"personal_letter_form_{subtype}"):
# Create tabs to organize the form sections.
tab1, tab2, tab3 = st.tabs(["Letter Details", "Personal Info", "Preview & Export"])
# Dictionary to store form data collected from all tabs
form_data = {}
# --- Tab 1: Letter Details ---
with tab1:
st.markdown("### Letter Content Details")
# Get the configuration for subtype-specific input fields.
fields = get_fields_for_subtype(subtype)
# Create form fields dynamically based on the subtype configuration.
# Populate default values from session state to retain user input across reruns.
for field in fields:
# Retrieve default value from session state, falling back to empty string or specific defaults
default_value = st.session_state.personal_letter_form_data.get(field["id"], "")
# Create the appropriate Streamlit input widget based on the field type.
# Use a unique key for each widget to ensure state is managed correctly.
if field["type"] == "text":
form_data[field["id"]] = st.text_input(field["label"], value=default_value, help=field.get("help", ""), key=f"{subtype}_{field['id']}")
elif field["type"] == "textarea":
form_data[field["id"]] = st.text_area(field["label"], value=default_value, help=field.get("help", ""), height=150, key=f"{subtype}_{field['id']}")
elif field["type"] == "date":
# Handle date input default value: use stored value if valid, otherwise use today's date.
try:
# Attempt to parse stored value as date, fallback to today if unsuccessful
default_date = datetime.datetime.strptime(str(default_value), "%Y-%m-%d").date() if default_value else datetime.date.today()
except (ValueError, TypeError):
default_date = datetime.date.today() # Fallback to today's date
form_data[field["id"]] = st.date_input(field["label"], value=default_date, help=field.get("help", ""), key=f"{subtype}_{field['id']}")
elif field["type"] == "select":
# Determine the index of the default value in the options list.
try:
default_index = field["options"].index(default_value) if default_value in field["options"] else 0
except ValueError:
default_index = 0 # Default to the first option if the stored value is not valid
form_data[field["id"]] = st.selectbox(field["label"], field["options"], index=default_index, help=field.get("help", ""), key=f"{subtype}_{field['id']}")
elif field["type"] == "slider":
# Use the default value from session state or the field config's default
default_slider_value = st.session_state.personal_letter_form_data.get(field["id"], field.get("default", (field["min"] + field["max"]) / 2)) # Fallback to midpoint if no default specified
form_data[field["id"]] = st.slider(field["label"], field["min"], field["max"], default_slider_value, help=field.get("help", ""), key=f"{subtype}_{field['id']}")
# Section for selecting letter tone and style characteristics.
st.markdown("### Tone and Style")
col1, col2 = st.columns(2)
with col1:
# Selectbox for Tone, using subtype-specific tones and session state default.
tone_options = ["Formal", "Warm", "Casual", "Intimate", "Playful"]
default_tone = st.session_state.personal_letter_form_data.get("tone", get_default_tone_for_subtype(subtype))
form_data["tone"] = st.select_slider(
"Tone",
options=tone_options,
value=default_tone, # select_slider uses value directly
help="Select the overall tone for your letter.",
key=f"{subtype}_tone"
)
# Selectbox for Emotional Tone, using subtype-specific emotions and session state default.
emotion_options = get_emotions_for_subtype(subtype)
default_emotion = st.session_state.personal_letter_form_data.get("emotion", emotion_options[0] if emotion_options else "Sincere")
form_data["emotion"] = st.selectbox(
"Emotional Tone",
emotion_options,
index=emotion_options.index(default_emotion) if default_emotion in emotion_options else 0,
help="Select the primary emotional tone for your letter.",
key=f"{subtype}_emotion"
)
with col2:
# Slider for Length, using session state default.
length_options = ["Brief", "Standard", "Detailed", "Extensive"]
default_length = st.session_state.personal_letter_form_data.get("length", "Standard")
form_data["length"] = st.select_slider(
"Length",
options=length_options,
value=default_length,
help="Select the desired length of your letter.",
key=f"{subtype}_length"
)
# Selectbox for Writing Style, using session state default.
style_options = ["Straightforward", "Descriptive", "Reflective", "Poetic", "Conversational"]
default_style = st.session_state.personal_letter_form_data.get("style", "Conversational")
form_data["style"] = st.selectbox(
"Writing Style",
style_options,
index=style_options.index(default_style) if default_style in style_options else 0,
help="Select the overall writing style for your letter.",
key=f"{subtype}_style"
)
# Section for adding personal touches.
st.markdown("### Personal Touches")
# Checkbox and textarea for including a shared memory.
default_include_memory = st.session_state.personal_letter_form_data.get("include_memory", True)
include_memory = st.checkbox("Include a shared memory", value=default_include_memory, help="Include a specific memory you share with the recipient.", key=f"{subtype}_include_memory")
form_data["shared_memory"] = None # Initialize to None
if include_memory:
default_shared_memory = st.session_state.personal_letter_form_data.get("shared_memory", "")
form_data["shared_memory"] = st.text_area(
"Shared Memory Details",
value=default_shared_memory,
height=100,
help="Describe the shared memory.",
placeholder="e.g., Remember when we went hiking last summer and got caught in the rain?",
key=f"{subtype}_shared_memory"
)
# Checkbox and textarea for including future plans/wishes.
default_include_future = st.session_state.personal_letter_form_data.get("include_future", True)
include_future = st.checkbox("Include future plans or wishes", value=default_include_future, help="Mention upcoming plans or express wishes for their future.", key=f"{subtype}_include_future")
form_data["future_plans"] = None # Initialize to None
if include_future:
default_future_plans = st.session_state.personal_letter_form_data.get("future_plans", "")
form_data["future_plans"] = st.text_area(
"Future Plans or Wishes Details",
value=default_future_plans,
height=100,
help="Describe the future plans or wishes.",
placeholder="e.g., I'm looking forward to seeing you at the family reunion next month.",
key=f"{subtype}_future_plans"
)
# Advanced options expander for less common personal touches.
with st.expander("Advanced Options"):
# Checkbox and text input for including a quote.
default_include_quote = st.session_state.personal_letter_form_data.get("include_quote", False)
include_quote = st.checkbox("Include a quote or saying", value=default_include_quote, help="Add a relevant quote, saying, or verse.", key=f"{subtype}_include_quote")
form_data["quote"] = None # Initialize to None
if include_quote:
default_quote = st.session_state.personal_letter_form_data.get("quote", "")
form_data["quote"] = st.text_input(
"Quote or Saying Text",
value=default_quote,
help="Enter the quote or saying.",
placeholder="e.g., 'True friendship is a plant of slow growth.' - George Washington",
key=f"{subtype}_quote"
)
# Checkbox and text input for including an inside joke.
default_include_inside_joke = st.session_state.personal_letter_form_data.get("include_inside_joke", False)
include_inside_joke = st.checkbox("Include an inside joke", value=default_include_inside_joke, help="Add a reference only you and the recipient will understand.", key=f"{subtype}_include_inside_joke")
form_data["inside_joke"] = None # Initialize to None
if include_inside_joke:
default_inside_joke = st.session_state.personal_letter_form_data.get("inside_joke", "")
form_data["inside_joke"] = st.text_input(
"Inside Joke Details",
value=default_inside_joke,
help="Describe the inside joke.",
placeholder="e.g., Don't worry, I won't bring up the 'flamingo incident' again!",
key=f"{subtype}_inside_joke"
)
# --- Tab 2: Personal Info ---
with tab2:
# Section for sender and recipient personal information.
col3, col4 = st.columns(2)
with col3:
st.markdown("### Your Information")
# Input fields for sender's personal details, populated from session state.
form_data["sender_name"] = st.text_input("Your Name", value=st.session_state.personal_letter_form_data.get("sender_name", ""), help="Your full name or how you sign your letters.", key=f"{subtype}_sender_name")
form_data["sender_nickname"] = st.text_input("Your Nickname (Optional)", value=st.session_state.personal_letter_form_data.get("sender_nickname", ""), help="A nickname you use with the recipient, if applicable.", key=f"{subtype}_sender_nickname")
form_data["relationship"] = st.text_input("Your Relationship to Recipient", value=st.session_state.personal_letter_form_data.get("relationship", ""), help="Describe your relationship (e.g., Friend, Sister, Uncle, Partner).", key=f"{subtype}_relationship")
# Selectbox for relationship duration.
relationship_durations = ["Less than a year", "1-5 years", "5-10 years", "10+ years", "Lifelong"]
default_relationship_duration = st.session_state.personal_letter_form_data.get("relationship_duration", "1-5 years")
form_data["relationship_duration"] = st.selectbox(
"How long have you known the recipient?",
relationship_durations,
index=relationship_durations.index(default_relationship_duration) if default_relationship_duration in relationship_durations else 0,
help="Select the approximate duration of your relationship.",
key=f"{subtype}_relationship_duration"
)
with col4:
st.markdown("### Recipient Information")
# Input fields for recipient's personal details, populated from session state.
form_data["recipient_name"] = st.text_input("Recipient's Name", value=st.session_state.personal_letter_form_data.get("recipient_name", ""), help="The recipient's full name or how you address them.", key=f"{subtype}_recipient_name")
form_data["recipient_nickname"] = st.text_input("Recipient's Nickname (Optional)", value=st.session_state.personal_letter_form_data.get("recipient_nickname", ""), help="A nickname you use for the recipient, if applicable.", key=f"{subtype}_recipient_nickname")
# Multiselect for recipient characteristics.
recipient_traits_options = ["Funny", "Serious", "Creative", "Practical", "Emotional", "Reserved", "Outgoing", "Thoughtful", "Adventurous", "Kind", "Intelligent", "Quiet", "Loud", "Supportive", "Independent"] # Expanded options
default_recipient_traits = st.session_state.personal_letter_form_data.get("recipient_traits", []) # Default to empty list
# Ensure default_recipient_traits is a list for multiselect
if isinstance(default_recipient_traits, str):
default_recipient_traits = [trait.strip() for trait in default_recipient_traits.split(',') if trait.strip()]
form_data["recipient_traits"] = st.multiselect(
"Recipient's Characteristics",
recipient_traits_options,
default=default_recipient_traits,
help="Select traits that describe the recipient. This helps tailor the language.",
key=f"{subtype}_recipient_traits"
)
# Text area for special considerations.
form_data["special_considerations"] = st.text_area(
"Special Considerations (Optional)",
value=st.session_state.personal_letter_form_data.get("special_considerations", ""),
height=100,
help="Mention any sensitive topics to avoid or specific circumstances to acknowledge (e.g., they recently lost a pet, celebrating sobriety).",
placeholder="e.g., Recently lost a job, celebrating sobriety milestone",
key=f"{subtype}_special_considerations"
)
# --- Tab 3: Preview & Export ---
with tab3:
# Instructions for the user before generation.
if not st.session_state.personal_letter_generated:
st.info("Complete the letter details and click 'Generate Letter' to preview your letter.")
# The Generate button is placed inside the form. Clicking it submits the form
# and triggers the code block below it to run.
generate_button = st.form_submit_button("Generate Letter", type="primary")
if generate_button:
# Action to perform when the form is submitted via the Generate button.
# Store the current state of all form inputs in session state.
# This allows retaining user inputs even after generation or regeneration.
st.session_state.personal_letter_form_data = form_data.copy()
# Prepare metadata specifically for the formatter and analysis functions.
# This includes structured contact info, dates, salutation, etc.
metadata = {
"sender_name": form_data.get("sender_name", ""),
"sender_nickname": form_data.get("sender_nickname", ""),
"recipient_name": form_data.get("recipient_name", ""),
"recipient_nickname": form_data.get("recipient_nickname", ""),
"relationship": form_data.get("relationship", ""),
"relationship_duration": form_data.get("relationship_duration", ""),
# Convert list of traits back to a string for metadata if needed by formatter/analyzer
"recipient_traits": ", ".join(form_data.get("recipient_traits", [])) if form_data.get("recipient_traits") else "",
"special_considerations": form_data.get("special_considerations", ""),
"date": datetime.datetime.now().strftime("%B %d, %Y"), # Use current date for the letter
}
# Determine salutation based on nickname preference
recipient_display_name = metadata["recipient_nickname"] if metadata.get("recipient_nickname") else metadata["recipient_name"]
metadata["salutation"] = f"Dear {recipient_display_name}," if recipient_display_name else "Dear Friend," # Fallback salutation
# Determine complimentary close based on tone/formality - simple logic for now
metadata["complimentary_close"] = "Warmly," if form_data.get("tone") in ["Warm", "Intimate", "Playful"] else "Sincerely,"
st.session_state.personal_letter_metadata = metadata.copy()
# --- Letter Generation Logic ---
# Check for minimal required fields before attempting generation.
if not form_data.get("sender_name") or not form_data.get("recipient_name"):
st.error("Please provide at least your name and the recipient's name.")
else:
# Display a spinner while the AI generates the letter.
with st.spinner("Generating your personal letter..."):
# Combine all necessary data into a single dictionary for the generation function.
# This includes both form data and metadata.
# Note: tone, emotion, length, style are already in form_data
generation_data = {
"subtype": subtype,
**form_data, # Includes all collected form inputs
**metadata # Includes structured sender/recipient/date/relationship info
}
# Call the letter generation function with the combined data.
letter_content = generate_personal_letter(generation_data)
# Store the generated letter content and update the generated flag.
st.session_state.personal_letter_content = letter_content
st.session_state.personal_letter_generated = True
# Rerun the app to exit the form block and display the generated letter section.
# This rerun happens automatically on form submission, but explicit state updates
# ensure the display logic reacts correctly.
# st.rerun() # Rerun is handled by form submission
# --- Display Generated Letter and Analysis ---
# This block executes if a letter has been generated and stored in session state.
if st.session_state.personal_letter_generated and st.session_state.personal_letter_content is not None:
letter_content = st.session_state.personal_letter_content
metadata = st.session_state.personal_letter_metadata
# Create tabs for different views of the generated letter.
preview_tab1, preview_tab2, preview_tab3 = st.tabs(["Formatted Preview", "Plain Text", "Analysis"])
with preview_tab1:
st.markdown("### Letter Preview")
# Generate and display the HTML preview of the letter using the formatter utility.
# Pass letter_type="personal" to the formatter.
html_preview = get_letter_preview_html(letter_content, metadata, letter_type="personal")
st.markdown(html_preview, unsafe_allow_html=True)
# Download button for the plain text version of the letter.
file_name_suffix = metadata.get('recipient_name', 'personal').replace(' ', '_').lower()
st.download_button(
label="Download as Text",
data=letter_content,
file_name=f"{subtype}_letter_to_{file_name_suffix}_{datetime.datetime.now().strftime('%Y%m%d')}.txt",
mime="text/plain"
)
with preview_tab2:
st.markdown("### Plain Text Content")
# Display the raw generated letter content in a text area.
st.text_area("Letter Content", letter_content, height=400, key=f"{subtype}_plain_text_display")
# Button to copy the plain text content to the clipboard.
st.button("Copy Plain Text (Manual Copy from above)", help="Select and copy the text from the box above.", key=f"{subtype}_copy_plain_text_instruction")
with preview_tab3:
st.markdown("### Letter Analysis")
# Perform and display analysis of the generated letter using utility functions.
# Analyze tone, formality, and readability.
tone_analysis = analyze_letter_tone(letter_content)
formality_score = check_formality(letter_content) # Returns score between 0.0 and 1.0
readability_metrics = get_readability_metrics(letter_content)
# Get improvement suggestions, passing the letter type for context.
improvement_suggestions = suggest_improvements(letter_content, "personal") # Pass "personal" as letter_type
# Display analysis results in two columns.
col5, col6 = st.columns(2)
with col5:
st.markdown("#### Tone Analysis")
# Display each tone score.
if tone_analysis:
for tone, score in tone_analysis.items():
st.write(f"- **{tone.capitalize()}:** {score:.2f}")
else:
st.info("Tone analysis not available.")
st.markdown("#### Formality")
# Display formality score as a percentage and a progress bar.
st.progress(formality_score) # Progress bar expects a value between 0.0 and 1.0
st.write(f"Formality Score: {formality_score * 100:.0f}/100") # Display as a percentage (0-100)
with col6:
st.markdown("#### Readability Metrics")
# Display various readability metrics.
if readability_metrics:
st.write(f"**Word Count:** {readability_metrics.get('word_count', 'N/A')} words")
st.write(f"**Sentence Count:** {readability_metrics.get('sentence_count', 'N/A')} sentences")
st.write(f"**Avg Words per Sentence:** {readability_metrics.get('avg_words_per_sentence', 'N/A')}")
st.write(f"**Flesch Reading Ease:** {readability_metrics.get('flesch_reading_ease', 'N/A')}")
st.write(f"**Reading Level:** {readability_metrics.get('reading_level', 'N/A')}")
# Display estimated reading time.
st.write(f"**Estimated Reading Time:** {readability_metrics.get('reading_time_seconds', 'N/A')} seconds")
else:
st.info("Readability metrics not available.")
st.markdown("#### Suggestions for Improvement")
# Display improvement suggestions.
if improvement_suggestions:
# Iterate through the list and display each suggestion as a list item.
for suggestion in improvement_suggestions:
st.markdown(f"- {suggestion}")
else:
st.info("No specific suggestions for improvement found.")
# Button to regenerate the letter. Placed outside the form so it's always visible
# after generation, without needing to resubmit the form first.
# Keep the form data in session state so the user's inputs are retained.
if st.button("Regenerate Letter", key=f"{subtype}_regenerate_button"):
# Reset the generated state and content to allow the form to be displayed again.
st.session_state.personal_letter_generated = False
st.session_state.personal_letter_content = None # Clear generated content
# st.session_state.personal_letter_form_data is already populated from the form submit
st.rerun() # Rerun to show the form with previous inputs
def generate_personal_letter(data: Dict[str, Any]) -> str:
"""
Generates a personal letter using the LLM by constructing a detailed prompt
based on the collected user inputs and metadata.
Args:
data: A dictionary containing all collected user inputs and metadata
(from the form and session state).
Returns:
The generated letter content as a string, or an error message if generation fails.
"""
# Extract key generation parameters from the data dictionary.
subtype = data.get("subtype", "default")
tone = data.get("tone", "Warm")
emotion = data.get("emotion", "Sincere")
length = data.get("length", "Standard")
style = data.get("style", "Conversational")
# Get template guidance and structure to include in the prompt.
template = get_template_by_type("personal", subtype)
template_guidance = template.get("guidance", "Follow standard personal letter practices.")
template_structure = template.get("structure", ["Greeting", "Body", "Closing"])
# Build the prompt string step-by-step, including all relevant details
# from the user's input and selected options.
prompt_parts = [
f"Write a {length.lower()} personal {get_name_for_subtype(subtype)} letter with a {tone.lower()}, {emotion.lower()} tone and {style.lower()} writing style.",
f"Purpose: {get_description_for_subtype(subtype)}",
f"Recipient: {data.get('recipient_name', '')} ({data.get('recipient_nickname', '') if data.get('recipient_nickname') else 'no nickname'})",
f"Sender: {data.get('sender_name', '')} ({data.get('sender_nickname', '') if data.get('sender_nickname') else 'no nickname'})",
f"Relationship: {data.get('relationship', 'Not specified')} for {data.get('relationship_duration', 'Not specified')}",
]
# Add recipient traits if provided.
if data.get('recipient_traits'):
# Ensure traits are listed nicely in the prompt
traits_list = ", ".join(data['recipient_traits']) if isinstance(data['recipient_traits'], list) else data['recipient_traits']
if traits_list:
prompt_parts.append(f"Recipient's Characteristics: {traits_list}")
# Add subtype-specific details from the collected form data.
subtype_fields = get_fields_for_subtype(subtype)
if subtype_fields:
prompt_parts.append("\nKey Details to Include:")
for field in subtype_fields:
field_value = data.get(field["id"])
# Include the field's label and value in the prompt only if the value is not empty.
if field_value:
# Format date fields nicely for the prompt if they are date objects.
if field["type"] == "date":
try:
field_value_str = field_value.strftime("%B %d, %Y")
except AttributeError:
field_value_str = str(field_value) # Fallback if not a date object
else:
field_value_str = str(field_value)
prompt_parts.append(f"- {field['label']}: {field_value_str}")
# Add personal touches if included.
if data.get('include_memory') and data.get('shared_memory'):
prompt_parts.append(f"Include this shared memory: {data['shared_memory']}")
if data.get('include_future') and data.get('future_plans'):
prompt_parts.append(f"Include these future plans or wishes: {data['future_plans']}")
if data.get('include_quote') and data.get('quote'):
prompt_parts.append(f"Include this quote: {data['quote']}")
if data.get('include_inside_joke') and data.get('inside_joke'):
prompt_parts.append(f"Include this inside joke: {data['inside_joke']}")
# Add special considerations if provided.
if data.get('special_considerations'):
prompt_parts.append(f"\nSpecial considerations: {data['special_considerations']}")
# Add the template structure and overall guidance to the prompt.
# This helps the LLM understand the desired layout and writing style.
prompt_parts.append("\nFollow this general structure:")
for i, section in enumerate(template_structure):
prompt_parts.append(f"{i+1}. {section}")
prompt_parts.append(f"\nOverall Writing Guidance: {template_guidance}")
# Add final instructions for the LLM.
prompt_parts.append("\nMake the letter personal, authentic, and appropriate for the relationship described. Use natural language that sounds like it was written by a real person, not AI.")
# Combine all prompt parts into a single string.
final_prompt = "\n".join(prompt_parts)
# Call the LLM text generation function with the constructed prompt.
try:
letter_content = llm_text_gen(final_prompt)
return letter_content
except Exception as e:
# Catch any errors during LLM generation and display an error message.
st.error(f"Error generating letter: {str(e)}")
return "Error generating letter. Please try again."
# --- Helper functions (from original code, slightly enhanced) ---
def get_icon_for_subtype(subtype: str) -> str:
"""Maps a personal letter subtype ID to a relevant emoji icon."""
icons = {
"congratulations": "🎉",
"thank_you": "🙏",
"sympathy": "💐",
"apology": "🙇",
"invitation": "✉️",
"friendship": "👫",
"love": "❤️",
"encouragement": "🌟",
"farewell": "👋"
}
return icons.get(subtype, "📝") # Default icon
def get_name_for_subtype(subtype: str) -> str:
"""Maps a personal letter subtype ID to its display name."""
names = {
"congratulations": "Congratulations Letter",
"thank_you": "Thank You Letter",
"sympathy": "Sympathy Letter",
"apology": "Apology Letter",
"invitation": "Invitation Letter",
"friendship": "Friendship Letter",
"love": "Love Letter",
"encouragement": "Encouragement Letter",
"farewell": "Farewell Letter"
}
return names.get(subtype, "Personal Letter") # Default name
def get_description_for_subtype(subtype: str) -> str:
"""Maps a personal letter subtype ID to a brief description."""
descriptions = {
"congratulations": "Celebrate achievements and milestones with a heartfelt congratulations letter.",
"thank_you": "Express gratitude for gifts, help, or support with a sincere thank you letter.",
"sympathy": "Offer comfort and support during difficult times with a thoughtful sympathy letter.",
"apology": "Say sorry and make amends with a sincere apology letter.",
"invitation": "Invite someone to an event or gathering with a personal invitation letter.",
"friendship": "Nurture and celebrate friendships with a meaningful letter.",
"love": "Express romantic feelings and affection with a heartfelt love letter.",
"encouragement": "Offer support and motivation with an uplifting encouragement letter.",
"farewell": "Say goodbye to friends or colleagues with a touching farewell letter."
}
return descriptions.get(subtype, "Create a personalized letter for your specific needs.") # Default description
def get_fields_for_subtype(subtype: str) -> List[Dict[str, Any]]:
"""
Provides a list of input field configurations specific to each personal letter subtype.
Each dictionary in the list defines a form input field, including its ID, label,
type, and optional properties like help text, options (for select/slider),
min/max values (for slider/number), and a default value.
"""
# Define subtype-specific fields.
if subtype == "congratulations":
return [
{
"id": "achievement",
"label": "Achievement Being Celebrated",
"type": "text",
"help": "What specific achievement or milestone are you congratulating them for? Be as specific as possible."
},
{
"id": "significance",
"label": "Significance of Achievement",
"type": "textarea",
"help": "Explain why this achievement is significant to you or to them. How does it make you feel?"
}
]
elif subtype == "thank_you":
return [
{
"id": "reason",
"label": "Reason for Thanks",
"type": "text",
"help": "What specific gift, act of kindness, or support are you thanking them for?"
},
{
"id": "impact",
"label": "Impact of Their Action",
"type": "textarea",
"help": "Explain how their action or gift specifically helped or affected you. Be sincere."
}
]
elif subtype == "sympathy":
return [
{
"id": "reason",
"label": "Reason for Sympathy",
"type": "text",
"help": "What loss or difficult situation are you expressing sympathy for? (e.g., Loss of a loved one, difficult time)"
},
{
"id": "relationship_to_affected",
"label": "Recipient's Relationship to the Deceased/Affected (Optional)",
"type": "text",
"help": "What was the recipient's relationship to the person who passed away or is affected, if applicable? (e.g., Their mother, their pet)"
},
{
"id": "positive_memory",
"label": "Share a Positive Memory (Optional)",
"type": "textarea",
"help": "If appropriate, share a brief, positive memory of the person who was lost or the situation."
},
{
"id": "offer_of_support",
"label": "Offer of Support (Optional)",
"type": "textarea",
"help": "Offer specific ways you can provide support (e.g., 'I'm here to listen anytime', 'I can help with meals')."
}
]
elif subtype == "apology":
return [
{
"id": "reason",
"label": "What You Are Apologizing For",
"type": "textarea",
"help": "Clearly and specifically state what you are apologizing for. Take responsibility."
},
{
"id": "impact",
"label": "Acknowledge Impact of Actions",
"type": "textarea",
"help": "Show that you understand how your actions affected the recipient and acknowledge their feelings."
},
{
"id": "amends",
"label": "Proposed Amends or How You Will Make Things Right (Optional)",
"type": "textarea",
"help": "Suggest ways you can make amends or explain what you will do to prevent it from happening again."
}
]
elif subtype == "invitation":
return [
{
"id": "event",
"label": "Event Name",
"type": "text",
"help": "What is the name or type of event? (e.g., Birthday Party, Dinner Gathering, Wedding)"
},
{
"id": "date_time",
"label": "Date and Time",
"type": "text", # Using text for flexibility (e.g., "Saturday, November 18th at 7:00 PM")
"help": "When is the event taking place? Include day, date, and time."
},
{
"id": "location",
"label": "Location",
"type": "text",
"help": "Where is the event taking place? Include the full address if necessary."
},
{
"id": "purpose_theme",
"label": "Purpose or Theme (Optional)",
"type": "text",
"help": "Briefly mention the purpose or theme of the event."
},
{
"id": "rsvp_details",
"label": "RSVP Details",
"type": "textarea",
"help": "Specify how and by when they should RSVP (e.g., 'Please RSVP by November 10th to [Your Email/Phone]')."
}
]
elif subtype == "friendship":
return [
{
"id": "occasion",
"label": "Occasion for Writing (Optional)",
"type": "text",
"help": "Is there a specific reason for writing? (e.g., friendship anniversary, just thinking of you)"
},
{
"id": "valued_aspects",
"label": "Valued Aspects of Friendship",
"type": "textarea",
"help": "What specific qualities or moments do you value most about your friendship with this person?"
},
{
"id": "recent_update",
"label": "Recent Update or Shared Experience (Optional)",
"type": "textarea",
"help": "Mention something recent you've shared or an update in your life."
}
]
elif subtype == "love":
return [
{
"id": "occasion",
"label": "Occasion for Writing (Optional)",
"type": "text",
"help": "Is there a specific reason for writing? (e.g., anniversary, Valentine's Day, just because)"
},
{
"id": "feelings",
"label": "Feelings to Express",
"type": "textarea",
"help": "Describe the depth and nature of your feelings for your loved one."
},
{
"id": "special_memories_love", # Added suffix
"label": "Special Memories",
"type": "textarea",
"help": "Recall and describe cherished memories you share."
},
{
"id": "qualities_loved",
"label": "Qualities You Love and Appreciate",
"type": "textarea",
"help": "Mention specific qualities you adore about them."
}
]
elif subtype == "encouragement":
return [
{
"id": "situation",
"label": "Situation Requiring Encouragement",
"type": "textarea",
"help": "Describe the specific challenge or situation the recipient is facing."
},
{
"id": "strengths",
"label": "Strengths and Abilities to Highlight",
"type": "textarea",
"help": "Remind them of their strengths, resilience, or past successes that will help them through this."
},
{
"id": "belief_statement",
"label": "Statement of Belief in Them",
"type": "textarea",
"help": "Clearly state your confidence in their ability to overcome the challenge."
}
]
elif subtype == "farewell":
return [
{
"id": "reason",
"label": "Reason for Farewell",
"type": "text",
"help": "Why are you or the recipient saying goodbye? (e.g., Moving away, new job, retirement)"
},
{
"id": "memories_farewell", # Added suffix
"label": "Memories to Mention",
"type": "textarea",
"help": "Share positive memories you have with the recipient."
},
{
"id": "wishes",
"label": "Future Wishes",
"type": "textarea",
"help": "Express your sincere good wishes for their future endeavors."
},
{
"id": "stay_in_touch",
"label": "How to Stay in Touch (Optional)",
"type": "textarea",
"help": "Suggest ways to keep in touch (e.g., 'Let's connect on LinkedIn', 'I'll visit when I can')."
}
]
# Default fields if subtype is not recognized or no specific fields are defined.
# This provides a basic textarea for general content.
return [
{
"id": "main_content",
"label": "Main Content",
"type": "textarea",
"help": "Enter the main content you want to include in your personal letter."
}
]
def get_default_tone_for_subtype(subtype: str) -> str:
"""Maps a personal letter subtype ID to a suggested default tone."""
tones = {
"congratulations": "Warm",
"thank_you": "Warm",
"sympathy": "Warm",
"apology": "Formal", # Apologies often require a more formal/serious tone initially
"invitation": "Casual",
"friendship": "Casual",
"love": "Intimate",
"encouragement": "Warm",
"farewell": "Warm"
}
return tones.get(subtype, "Warm") # Default tone
def get_emotions_for_subtype(subtype: str) -> List[str]:
"""Maps a personal letter subtype ID to a list of suggested emotional tones."""
emotions = {
"congratulations": ["Joyful", "Proud", "Excited", "Impressed", "Inspired", "Happy"],
"thank_you": ["Grateful", "Appreciative", "Touched", "Moved", "Thankful", "Humbled"],
"sympathy": ["Compassionate", "Caring", "Supportive", "Empathetic", "Gentle", "Sorrowful"],
"apology": ["Remorseful", "Sincere", "Humble", "Regretful", "Honest", "Contrite"],
"invitation": ["Excited", "Welcoming", "Enthusiastic", "Anticipatory", "Cheerful", "Friendly"],
"friendship": ["Appreciative", "Affectionate", "Nostalgic", "Grateful", "Warm", "Loyal"],
"love": ["Passionate", "Devoted", "Adoring", "Tender", "Affectionate", "Romantic"],
"encouragement": ["Supportive", "Optimistic", "Confident", "Reassuring", "Inspiring", "Hopeful"],
"farewell": ["Nostalgic", "Hopeful", "Bittersweet", "Appreciative", "Reflective", "Fond"]
}
# Return the list of emotions for the subtype, or a default list if not found.
return emotions.get(subtype, ["Sincere", "Warm", "Friendly", "Genuine", "Thoughtful"])
# Example of how to run the app (for local development using `streamlit run your_script_name.py`)
# Uncomment the lines below to make this script directly executable.
# if __name__ == "__main__":
# write_letter()