diff --git a/lib/ai_marketing_tools/.ai_backlinking.py.swp b/lib/ai_marketing_tools/.ai_backlinking.py.swp deleted file mode 100644 index 887420ef..00000000 Binary files a/lib/ai_marketing_tools/.ai_backlinking.py.swp and /dev/null differ diff --git a/lib/ai_marketing_tools/README.md b/lib/ai_marketing_tools/ai_backlinker/README.md similarity index 100% rename from lib/ai_marketing_tools/README.md rename to lib/ai_marketing_tools/ai_backlinker/README.md diff --git a/lib/ai_marketing_tools/ai_backlinking.py b/lib/ai_marketing_tools/ai_backlinker/ai_backlinking.py similarity index 100% rename from lib/ai_marketing_tools/ai_backlinking.py rename to lib/ai_marketing_tools/ai_backlinker/ai_backlinking.py diff --git a/lib/ai_marketing_tools/backlinking_ui_streamlit.py b/lib/ai_marketing_tools/ai_backlinker/backlinking_ui_streamlit.py similarity index 94% rename from lib/ai_marketing_tools/backlinking_ui_streamlit.py rename to lib/ai_marketing_tools/ai_backlinker/backlinking_ui_streamlit.py index 3b57319d..e5222671 100644 --- a/lib/ai_marketing_tools/backlinking_ui_streamlit.py +++ b/lib/ai_marketing_tools/ai_backlinker/backlinking_ui_streamlit.py @@ -1,7 +1,7 @@ import streamlit as st import pandas as pd from st_aggrid import AgGrid, GridOptionsBuilder, GridUpdateMode -from lib.ai_marketing_tools.ai_backlinking import find_backlink_opportunities, compose_personalized_email +from lib.ai_marketing_tools.ai_backlinker.ai_backlinking import find_backlink_opportunities, compose_personalized_email # Streamlit UI function diff --git a/lib/ai_marketing_tools/ai_google_ads_generator/README.md b/lib/ai_marketing_tools/ai_google_ads_generator/README.md new file mode 100644 index 00000000..129194c8 --- /dev/null +++ b/lib/ai_marketing_tools/ai_google_ads_generator/README.md @@ -0,0 +1,370 @@ +Google Ads Generator +Google Ads Generator Logo + +Overview +The Google Ads Generator is an AI-powered tool designed to create high-converting Google Ads based on industry best practices. This tool helps marketers, business owners, and advertising professionals create optimized ad campaigns that maximize ROI and conversion rates. + +By leveraging advanced AI algorithms and proven advertising frameworks, the Google Ads Generator creates compelling ad copy, suggests optimal keywords, generates relevant extensions, and provides performance predictions—all tailored to your specific business needs and target audience. + +Table of Contents +Features +Getting Started +User Interface +Ad Creation Process +Ad Types +Quality Analysis +Performance Simulation +Best Practices +Export Options +Advanced Features +Technical Details +FAQ +Troubleshooting +Updates and Roadmap +Features +Core Features +AI-Powered Ad Generation: Create compelling, high-converting Google Ads in seconds +Multiple Ad Types: Support for Responsive Search Ads, Expanded Text Ads, Call-Only Ads, and Dynamic Search Ads +Industry-Specific Templates: Tailored templates for 20+ industries +Ad Extensions Generator: Automatically create Sitelinks, Callouts, and Structured Snippets +Quality Score Analysis: Comprehensive scoring based on Google's quality factors +Performance Prediction: Estimate CTR, conversion rates, and ROI +A/B Testing: Generate multiple variations for testing +Export Options: Export to CSV, Excel, Google Ads Editor CSV, and JSON +Advanced Features +Keyword Research Integration: Find high-performing keywords for your ads +Competitor Analysis: Analyze competitor ads and identify opportunities +Landing Page Suggestions: Recommendations for landing page optimization +Budget Optimization: Suggestions for optimal budget allocation +Ad Schedule Recommendations: Identify the best times to run your ads +Audience Targeting Suggestions: Recommendations for demographic targeting +Local Ad Optimization: Special features for local businesses +E-commerce Ad Features: Product-specific ad generation +Getting Started +Prerequisites +Alwrity AI Writer platform +Basic understanding of Google Ads concepts +Information about your business, products/services, and target audience +Accessing the Tool +Navigate to the Alwrity AI Writer platform +Select "AI Google Ads Generator" from the tools menu +Follow the guided setup process +User Interface +The Google Ads Generator features a user-friendly, tabbed interface designed to guide you through the ad creation process: + +Tab 1: Ad Creation +This is where you'll input your business information and ad requirements: + +Business Information: Company name, industry, products/services +Campaign Goals: Select from options like brand awareness, lead generation, sales, etc. +Target Audience: Define your ideal customer +Ad Type Selection: Choose from available ad formats +USP and Benefits: Input your unique selling propositions and key benefits +Keywords: Add target keywords or generate suggestions +Landing Page URL: Specify where users will go after clicking your ad +Budget Information: Set daily/monthly budget for performance predictions +Tab 2: Ad Performance +After generating ads, this tab provides detailed analysis: + +Quality Score: Overall score (1-10) with detailed breakdown +Strengths & Improvements: What's good and what could be better +Keyword Relevance: Analysis of keyword usage in ad elements +CTR Prediction: Estimated click-through rate based on ad quality +Conversion Potential: Estimated conversion rate +Mobile Friendliness: Assessment of how well the ad performs on mobile +Ad Policy Compliance: Check for potential policy violations +Tab 3: Ad History +Keep track of your generated ads: + +Saved Ads: Previously generated and saved ads +Favorites: Ads you've marked as favorites +Version History: Track changes and iterations +Performance Notes: Add notes about real-world performance +Tab 4: Best Practices +Educational resources to improve your ads: + +Industry Guidelines: Best practices for your specific industry +Ad Type Tips: Specific guidance for each ad type +Quality Score Optimization: How to improve quality score +Extension Strategies: How to effectively use ad extensions +A/B Testing Guide: How to test and optimize your ads +Ad Creation Process +Step 1: Define Your Campaign +Select your industry from the dropdown menu +Choose your primary campaign goal +Define your target audience +Set your budget parameters +Step 2: Input Business Details +Enter your business name +Provide your website URL +Input your unique selling propositions +List key product/service benefits +Add any promotional offers or discounts +Step 3: Keyword Selection +Enter your primary keywords +Use the integrated keyword research tool to find additional keywords +Select keyword match types (broad, phrase, exact) +Review keyword competition and volume metrics +Step 4: Ad Type Selection +Choose your preferred ad type +Review the requirements and limitations for that ad type +Select any additional features specific to that ad type +Step 5: Generate Ads +Click the "Generate Ads" button +Review the generated ads +Request variations if needed +Save your favorite versions +Step 6: Add Extensions +Select which extension types to include +Review and edit the generated extensions +Add any custom extensions +Step 7: Analyze and Optimize +Review the quality score and analysis +Make suggested improvements +Regenerate ads if necessary +Compare different versions +Step 8: Export +Choose your preferred export format +Select which ads to include +Download the file for import into Google Ads +Ad Types +Responsive Search Ads (RSA) +The most flexible and recommended ad type, featuring: + +Up to 15 headlines (3 shown at a time) +Up to 4 descriptions (2 shown at a time) +Dynamic combination of elements based on performance +Automatic testing of different combinations +Expanded Text Ads (ETA) +A more controlled ad format with: + +3 headlines +2 descriptions +Display URL with two path fields +Fixed layout with no dynamic combinations +Call-Only Ads +Designed to drive phone calls rather than website visits: + +Business name +Phone number +Call-to-action text +Description lines +Verification URL (not shown to users) +Dynamic Search Ads (DSA) +Ads that use your website content to target relevant searches: + +Dynamic headline generation based on search queries +Custom descriptions +Landing page selection based on website content +Requires website URL for crawling +Quality Analysis +Our comprehensive quality analysis evaluates your ads based on factors that influence Google's Quality Score: + +Headline Analysis +Keyword Usage: Presence of keywords in headlines +Character Count: Optimal length for visibility +Power Words: Use of emotionally compelling words +Clarity: Clear communication of value proposition +Call to Action: Presence of action-oriented language +Description Analysis +Keyword Density: Optimal keyword usage +Benefit Focus: Clear articulation of benefits +Feature Inclusion: Mention of key features +Urgency Elements: Time-limited offers or scarcity +Call to Action: Clear next steps for the user +URL Path Analysis +Keyword Inclusion: Relevant keywords in display paths +Readability: Clear, understandable paths +Relevance: Connection to landing page content +Overall Ad Relevance +Keyword-to-Ad Relevance: Alignment between keywords and ad copy +Ad-to-Landing Page Relevance: Consistency across the user journey +Intent Match: Alignment with search intent +Performance Simulation +Our tool provides data-driven performance predictions based on: + +Click-Through Rate (CTR) Prediction +Industry benchmarks +Ad quality factors +Keyword competition +Ad position estimates +Conversion Rate Prediction +Industry averages +Landing page quality +Offer strength +Call-to-action effectiveness +Cost Estimation +Keyword competition +Quality Score impact +Industry CPC averages +Budget allocation +ROI Calculation +Estimated clicks +Predicted conversions +Average conversion value +Cost projections +Best Practices +Our tool incorporates these Google Ads best practices: + +Headline Best Practices +Include primary keywords in at least 2 headlines +Use numbers and statistics when relevant +Address user pain points directly +Include your unique selling proposition +Create a sense of urgency when appropriate +Keep headlines under 30 characters for full visibility +Use title case for better readability +Include at least one call-to-action headline +Description Best Practices +Include primary and secondary keywords naturally +Focus on benefits, not just features +Address objections proactively +Include specific offers or promotions +End with a clear call to action +Use all available character space (90 characters per description) +Maintain consistent messaging with headlines +Include trust signals (guarantees, social proof, etc.) +Extension Best Practices +Create at least 8 sitelinks for maximum visibility +Use callouts to highlight additional benefits +Include structured snippets relevant to your industry +Ensure extensions don't duplicate headline content +Make each extension unique and valuable +Use specific, action-oriented language +Keep sitelink text under 25 characters for mobile visibility +Ensure landing pages for sitelinks are relevant and optimized +Campaign Structure Best Practices +Group closely related keywords together +Create separate ad groups for different themes +Align ad copy closely with keywords in each ad group +Use a mix of match types for each keyword +Include negative keywords to prevent irrelevant clicks +Create separate campaigns for different goals or audiences +Set appropriate bid adjustments for devices, locations, and schedules +Implement conversion tracking for performance measurement +Export Options +The Google Ads Generator offers multiple export formats to fit your workflow: + +CSV Format +Standard CSV format compatible with most spreadsheet applications +Includes all ad elements and extensions +Contains quality score and performance predictions +Suitable for analysis and record-keeping +Excel Format +Formatted Excel workbook with multiple sheets +Separate sheets for ads, extensions, and analysis +Includes charts and visualizations of predicted performance +Color-coded quality indicators +Google Ads Editor CSV +Specially formatted CSV for direct import into Google Ads Editor +Follows Google's required format specifications +Includes all necessary fields for campaign creation +Ready for immediate upload to Google Ads Editor +JSON Format +Structured data format for programmatic use +Complete ad data in machine-readable format +Suitable for integration with other marketing tools +Includes all metadata and analysis results +Advanced Features +Keyword Research Integration +Access to keyword volume data +Competition analysis +Cost-per-click estimates +Keyword difficulty scores +Seasonal trend information +Question-based keyword suggestions +Long-tail keyword recommendations +Competitor Analysis +Identify competitors bidding on similar keywords +Analyze competitor ad copy and messaging +Identify gaps and opportunities +Benchmark your ads against competitors +Receive suggestions for differentiation +Landing Page Suggestions +Alignment with ad messaging +Key elements to include +Conversion optimization tips +Mobile responsiveness recommendations +Page speed improvement suggestions +Call-to-action placement recommendations +Local Ad Optimization +Location extension suggestions +Local keyword recommendations +Geo-targeting strategies +Local offer suggestions +Community-focused messaging +Location-specific call-to-actions +Technical Details +System Requirements +Modern web browser (Chrome, Firefox, Safari, Edge) +Internet connection +Access to Alwrity AI Writer platform +Data Privacy +No permanent storage of business data +Secure processing of all inputs +Option to save ads to your account +Compliance with data protection regulations +API Integration +Available API endpoints for programmatic access +Documentation for developers +Rate limits and authentication requirements +Sample code for common use cases +FAQ +General Questions +Q: How accurate are the performance predictions? A: Performance predictions are based on industry benchmarks and Google's published data. While they provide a good estimate, actual performance may vary based on numerous factors including competition, seasonality, and market conditions. + +Q: Can I edit the generated ads? A: Yes, all generated ads can be edited before export. You can modify headlines, descriptions, paths, and extensions to better fit your needs. + +Q: How many ads can I generate? A: The tool allows unlimited ad generation within your Alwrity subscription limits. + +Q: Are the generated ads compliant with Google's policies? A: The tool is designed to create policy-compliant ads, but we recommend reviewing Google's latest advertising policies as they may change over time. + +Technical Questions +Q: Can I import my existing ads for optimization? A: Currently, the tool does not support importing existing ads, but this feature is on our roadmap. + +Q: How do I import the exported files into Google Ads? A: For Google Ads Editor CSV files, open Google Ads Editor, go to File > Import, and select your exported file. For other formats, you may need to manually create campaigns using the generated content. + +Q: Can I schedule automatic ad generation? A: Automated scheduling is not currently available but is planned for a future release. + +Troubleshooting +Common Issues +Issue: Generated ads don't include my keywords Solution: Ensure your keywords are relevant to your business description and offerings. Try using more specific keywords or providing more detailed business information. + +Issue: Quality score is consistently low Solution: Review the improvement suggestions in the Ad Performance tab. Common issues include keyword relevance, landing page alignment, and benefit clarity. + +Issue: Export file isn't importing correctly into Google Ads Editor Solution: Ensure you're selecting the "Google Ads Editor CSV" export format. If problems persist, check for special characters in your ad copy that might be causing formatting issues. + +Issue: Performance predictions seem unrealistic Solution: Adjust your industry selection and budget information to get more accurate predictions. Consider providing more specific audience targeting information. + +Updates and Roadmap +Recent Updates +Added support for Performance Max campaign recommendations +Improved keyword research integration +Enhanced mobile ad optimization +Added 5 new industry templates +Improved quality score algorithm +Coming Soon +Competitor ad analysis tool +A/B testing performance simulator +Landing page builder integration +Automated ad scheduling recommendations +Video ad script generator +Google Shopping ad support +Multi-language ad generation +Custom template builder +Support +For additional help with the Google Ads Generator: + +Visit our Help Center +Email support at support@example.com +Join our Community Forum +License +The Google Ads Generator is part of the Alwrity AI Writer platform and is subject to the platform's terms of service and licensing agreements. + +Acknowledgments +Google Ads API documentation +Industry best practices from leading digital marketing experts +User feedback and feature requests +Last updated: [Current Date] + +Version: 1.0.0 \ No newline at end of file diff --git a/lib/ai_marketing_tools/ai_google_ads_generator/__init__.py b/lib/ai_marketing_tools/ai_google_ads_generator/__init__.py new file mode 100644 index 00000000..634e577f --- /dev/null +++ b/lib/ai_marketing_tools/ai_google_ads_generator/__init__.py @@ -0,0 +1,9 @@ +""" +Google Ads Generator Module + +This module provides functionality for generating high-converting Google Ads. +""" + +from .google_ads_generator import write_google_ads + +__all__ = ["write_google_ads"] \ No newline at end of file diff --git a/lib/ai_marketing_tools/ai_google_ads_generator/ad_analyzer.py b/lib/ai_marketing_tools/ai_google_ads_generator/ad_analyzer.py new file mode 100644 index 00000000..1680a271 --- /dev/null +++ b/lib/ai_marketing_tools/ai_google_ads_generator/ad_analyzer.py @@ -0,0 +1,327 @@ +""" +Ad Analyzer Module + +This module provides functions for analyzing and scoring Google Ads. +""" + +import re +from typing import Dict, List, Any, Tuple +import random +from urllib.parse import urlparse + +def analyze_ad_quality(ad: Dict, primary_keywords: List[str], secondary_keywords: List[str], +business_name: str, call_to_action: str) -> Dict: +""" +Analyze the quality of a Google Ad based on best practices. + +Args: +ad: Dictionary containing ad details +primary_keywords: List of primary keywords +secondary_keywords: List of secondary keywords +business_name: Name of the business +call_to_action: Call to action text + +Returns: +Dictionary with analysis results +""" +# Initialize results +strengths = [] +improvements = [] + +# Get ad components +headlines = ad.get("headlines", []) +descriptions = ad.get("descriptions", []) +path1 = ad.get("path1", "") +path2 = ad.get("path2", "") + +# Check headline count +if len(headlines) >= 10: +strengths.append("Good number of headlines (10+) for optimization") +elif len(headlines) >= 5: +strengths.append("Adequate number of headlines for testing") +else: +improvements.append("Add more headlines (aim for 10+) to give Google's algorithm more options") + +# Check description count +if len(descriptions) >= 4: +strengths.append("Good number of descriptions (4+) for optimization") +elif len(descriptions) >= 2: +strengths.append("Adequate number of descriptions for testing") +else: +improvements.append("Add more descriptions (aim for 4+) to give Google's algorithm more options") + +# Check headline length +long_headlines = [h for h in headlines if len(h) > 30] +if long_headlines: +improvements.append(f"{len(long_headlines)} headline(s) exceed 30 characters and may be truncated") +else: +strengths.append("All headlines are within the recommended length") + +# Check description length +long_descriptions = [d for d in descriptions if len(d) > 90] +if long_descriptions: +improvements.append(f"{len(long_descriptions)} description(s) exceed 90 characters and may be truncated") +else: +strengths.append("All descriptions are within the recommended length") + +# Check keyword usage in headlines +headline_keywords = [] +for kw in primary_keywords: +if any(kw.lower() in h.lower() for h in headlines): +headline_keywords.append(kw) + +if len(headline_keywords) == len(primary_keywords): +strengths.append("All primary keywords are used in headlines") +elif headline_keywords: +strengths.append(f"{len(headline_keywords)} out of {len(primary_keywords)} primary keywords used in headlines") +missing_kw = [kw for kw in primary_keywords if kw not in headline_keywords] +improvements.append(f"Add these primary keywords to headlines: {', '.join(missing_kw)}") +else: +improvements.append("No primary keywords found in headlines - add keywords to improve relevance") + +# Check keyword usage in descriptions +desc_keywords = [] +for kw in primary_keywords: +if any(kw.lower() in d.lower() for d in descriptions): +desc_keywords.append(kw) + +if len(desc_keywords) == len(primary_keywords): +strengths.append("All primary keywords are used in descriptions") +elif desc_keywords: +strengths.append(f"{len(desc_keywords)} out of {len(primary_keywords)} primary keywords used in descriptions") +missing_kw = [kw for kw in primary_keywords if kw not in desc_keywords] +improvements.append(f"Add these primary keywords to descriptions: {', '.join(missing_kw)}") +else: +improvements.append("No primary keywords found in descriptions - add keywords to improve relevance") + +# Check for business name +if any(business_name.lower() in h.lower() for h in headlines): +strengths.append("Business name is included in headlines") +else: +improvements.append("Consider adding your business name to at least one headline") + +# Check for call to action +if any(call_to_action.lower() in h.lower() for h in headlines) or any(call_to_action.lower() in d.lower() for d in descriptions): +strengths.append("Call to action is included in the ad") +else: +improvements.append(f"Add your call to action '{call_to_action}' to at least one headline or description") + +# Check for numbers and statistics +has_numbers = any(bool(re.search(r'\d+', h)) for h in headlines) or any(bool(re.search(r'\d+', d)) for d in descriptions) +if has_numbers: +strengths.append("Ad includes numbers or statistics which can improve CTR") +else: +improvements.append("Consider adding numbers or statistics to increase credibility and CTR") + +# Check for questions +has_questions = any('?' in h for h in headlines) or any('?' in d for d in descriptions) +if has_questions: +strengths.append("Ad includes questions which can engage users") +else: +improvements.append("Consider adding a question to engage users") + +# Check for emotional triggers +emotional_words = ['you', 'free', 'because', 'instantly', 'new', 'save', 'proven', 'guarantee', 'love', 'discover'] +has_emotional = any(any(word in h.lower() for word in emotional_words) for h in headlines) or \ +any(any(word in d.lower() for word in emotional_words) for d in descriptions) + +if has_emotional: +strengths.append("Ad includes emotional trigger words which can improve engagement") +else: +improvements.append("Consider adding emotional trigger words to increase engagement") + +# Check for path relevance +if any(kw.lower() in path1.lower() or kw.lower() in path2.lower() for kw in primary_keywords): +strengths.append("Display URL paths include keywords which improves relevance") +else: +improvements.append("Add keywords to your display URL paths to improve relevance") + +# Return the analysis results +return { +"strengths": strengths, +"improvements": improvements +} + +def calculate_quality_score(ad: Dict, primary_keywords: List[str], landing_page: str, ad_type: str) -> Dict: +""" +Calculate a quality score for a Google Ad based on best practices. + +Args: +ad: Dictionary containing ad details +primary_keywords: List of primary keywords +landing_page: Landing page URL +ad_type: Type of Google Ad + +Returns: +Dictionary with quality score components +""" +# Initialize scores +keyword_relevance = 0 +ad_relevance = 0 +cta_effectiveness = 0 +landing_page_relevance = 0 + +# Get ad components +headlines = ad.get("headlines", []) +descriptions = ad.get("descriptions", []) +path1 = ad.get("path1", "") +path2 = ad.get("path2", "") + +# Calculate keyword relevance (0-10) +# Check if keywords are in headlines, descriptions, and paths +keyword_in_headline = sum(1 for kw in primary_keywords if any(kw.lower() in h.lower() for h in headlines)) +keyword_in_description = sum(1 for kw in primary_keywords if any(kw.lower() in d.lower() for d in descriptions)) +keyword_in_path = sum(1 for kw in primary_keywords if kw.lower() in path1.lower() or kw.lower() in path2.lower()) + +# Calculate score based on keyword presence +if len(primary_keywords) > 0: +headline_score = min(10, (keyword_in_headline / len(primary_keywords)) * 10) +description_score = min(10, (keyword_in_description / len(primary_keywords)) * 10) +path_score = min(10, (keyword_in_path / len(primary_keywords)) * 10) + +# Weight the scores (headlines most important) +keyword_relevance = (headline_score * 0.6) + (description_score * 0.3) + (path_score * 0.1) +else: +keyword_relevance = 5 # Default score if no keywords provided + +# Calculate ad relevance (0-10) +# Check for ad structure and content quality + +# Check headline count and length +headline_count_score = min(10, (len(headlines) / 10) * 10) # Ideal: 10+ headlines +headline_length_score = 10 - min(10, (sum(1 for h in headlines if len(h) > 30) / max(1, len(headlines))) * 10) + +# Check description count and length +description_count_score = min(10, (len(descriptions) / 4) * 10) # Ideal: 4+ descriptions +description_length_score = 10 - min(10, (sum(1 for d in descriptions if len(d) > 90) / max(1, len(descriptions))) * 10) + +# Check for emotional triggers, questions, numbers +emotional_words = ['you', 'free', 'because', 'instantly', 'new', 'save', 'proven', 'guarantee', 'love', 'discover'] +emotional_score = min(10, sum(1 for h in headlines if any(word in h.lower() for word in emotional_words)) + +sum(1 for d in descriptions if any(word in d.lower() for word in emotional_words))) + +question_score = min(10, (sum(1 for h in headlines if '?' in h) + sum(1 for d in descriptions if '?' in d)) * 2) + +number_score = min(10, (sum(1 for h in headlines if bool(re.search(r'\d+', h))) + +sum(1 for d in descriptions if bool(re.search(r'\d+', d)))) * 2) + +# Calculate overall ad relevance score +ad_relevance = (headline_count_score * 0.15) + (headline_length_score * 0.15) + \ +(description_count_score * 0.15) + (description_length_score * 0.15) + \ +(emotional_score * 0.2) + (question_score * 0.1) + (number_score * 0.1) + +# Calculate CTA effectiveness (0-10) +# Check for clear call to action +cta_phrases = ['get', 'buy', 'shop', 'order', 'sign up', 'register', 'download', 'learn', 'discover', 'find', 'call', +'contact', 'request', 'start', 'try', 'join', 'subscribe', 'book', 'schedule', 'apply'] + +cta_in_headline = any(any(phrase in h.lower() for phrase in cta_phrases) for h in headlines) +cta_in_description = any(any(phrase in d.lower() for phrase in cta_phrases) for d in descriptions) + +if cta_in_headline and cta_in_description: +cta_effectiveness = 10 +elif cta_in_headline: +cta_effectiveness = 8 +elif cta_in_description: +cta_effectiveness = 7 +else: +cta_effectiveness = 4 + +# Calculate landing page relevance (0-10) +# In a real implementation, this would analyze the landing page content +# For this example, we'll use a simplified approach + +if landing_page: +# Check if domain seems relevant to keywords +domain = urlparse(landing_page).netloc + +# Check if keywords are in the domain or path +keyword_in_url = any(kw.lower() in landing_page.lower() for kw in primary_keywords) + +# Check if URL structure seems appropriate +has_https = landing_page.startswith('https://') + +# Calculate landing page score +landing_page_relevance = 5 # Base score + +if keyword_in_url: +landing_page_relevance += 3 + +if has_https: +landing_page_relevance += 2 + +# Cap at 10 +landing_page_relevance = min(10, landing_page_relevance) +else: +landing_page_relevance = 5 # Default score if no landing page provided + +# Calculate overall quality score (0-10) +overall_score = (keyword_relevance * 0.4) + (ad_relevance * 0.3) + (cta_effectiveness * 0.2) + (landing_page_relevance * 0.1) + +# Calculate estimated CTR based on quality score +# This is a simplified model - in reality, CTR depends on many factors +base_ctr = { +"Responsive Search Ad": 3.17, +"Expanded Text Ad": 2.83, +"Call-Only Ad": 3.48, +"Dynamic Search Ad": 2.69 +}.get(ad_type, 3.0) + +# Adjust CTR based on quality score (±50%) +quality_factor = (overall_score - 5) / 5 # -1 to 1 +estimated_ctr = base_ctr * (1 + (quality_factor * 0.5)) + +# Calculate estimated conversion rate +# Again, this is simplified - actual conversion rates depend on many factors +base_conversion_rate = 3.75 # Average conversion rate for search ads + +# Adjust conversion rate based on quality score (±40%) +estimated_conversion_rate = base_conversion_rate * (1 + (quality_factor * 0.4)) + +# Return the quality score components +return { +"keyword_relevance": round(keyword_relevance, 1), +"ad_relevance": round(ad_relevance, 1), +"cta_effectiveness": round(cta_effectiveness, 1), +"landing_page_relevance": round(landing_page_relevance, 1), +"overall_score": round(overall_score, 1), +"estimated_ctr": round(estimated_ctr, 2), +"estimated_conversion_rate": round(estimated_conversion_rate, 2) +} + +def analyze_keyword_relevance(keywords: List[str], ad_text: str) -> Dict: +""" +Analyze the relevance of keywords to ad text. + +Args: +keywords: List of keywords to analyze +ad_text: Combined ad text (headlines and descriptions) + +Returns: +Dictionary with keyword relevance analysis +""" +results = {} + +for keyword in keywords: +# Check if keyword is in ad text +is_present = keyword.lower() in ad_text.lower() + +# Check if keyword is in the first 100 characters +is_in_beginning = keyword.lower() in ad_text.lower()[:100] + +# Count occurrences +occurrences = ad_text.lower().count(keyword.lower()) + +# Calculate density +density = (occurrences * len(keyword)) / len(ad_text) * 100 if len(ad_text) > 0 else 0 + +# Store results +results[keyword] = { +"present": is_present, +"in_beginning": is_in_beginning, +"occurrences": occurrences, +"density": round(density, 2), +"optimal_density": 0.5 <= density <= 2.5 +} + +return results \ No newline at end of file diff --git a/lib/ai_marketing_tools/ai_google_ads_generator/ad_extensions_generator.py b/lib/ai_marketing_tools/ai_google_ads_generator/ad_extensions_generator.py new file mode 100644 index 00000000..83b733fa --- /dev/null +++ b/lib/ai_marketing_tools/ai_google_ads_generator/ad_extensions_generator.py @@ -0,0 +1,320 @@ +""" +Ad Extensions Generator Module + +This module provides functions for generating various types of Google Ads extensions. +""" + +from typing import Dict, List, Any, Optional +import re +from ...gpt_providers.text_generation.main_text_generation import llm_text_gen + +def generate_extensions(business_name: str, business_description: str, industry: str, +primary_keywords: List[str], unique_selling_points: List[str], +landing_page: str) -> Dict: +""" +Generate a complete set of ad extensions based on business information. + +Args: +business_name: Name of the business +business_description: Description of the business +industry: Industry of the business +primary_keywords: List of primary keywords +unique_selling_points: List of unique selling points +landing_page: Landing page URL + +Returns: +Dictionary with generated extensions +""" +# Generate sitelinks +sitelinks = generate_sitelinks(business_name, business_description, industry, primary_keywords, landing_page) + +# Generate callouts +callouts = generate_callouts(business_name, unique_selling_points, industry) + +# Generate structured snippets +snippets = generate_structured_snippets(business_name, business_description, industry, primary_keywords) + +# Return all extensions +return { +"sitelinks": sitelinks, +"callouts": callouts, +"structured_snippets": snippets +} + +def generate_sitelinks(business_name: str, business_description: str, industry: str, +primary_keywords: List[str], landing_page: str) -> List[Dict]: +""" +Generate sitelink extensions based on business information. + +Args: +business_name: Name of the business +business_description: Description of the business +industry: Industry of the business +primary_keywords: List of primary keywords +landing_page: Landing page URL + +Returns: +List of dictionaries with sitelink information +""" +# Define common sitelink types by industry +industry_sitelinks = { +"E-commerce": ["Shop Now", "Best Sellers", "New Arrivals", "Sale Items", "Customer Reviews", "About Us"], +"SaaS/Technology": ["Features", "Pricing", "Demo", "Case Studies", "Support", "Blog"], +"Healthcare": ["Services", "Locations", "Providers", "Insurance", "Patient Portal", "Contact Us"], +"Education": ["Programs", "Admissions", "Campus", "Faculty", "Student Life", "Apply Now"], +"Finance": ["Services", "Rates", "Calculators", "Locations", "Apply Now", "About Us"], +"Real Estate": ["Listings", "Sell Your Home", "Neighborhoods", "Agents", "Mortgage", "Contact Us"], +"Legal": ["Practice Areas", "Attorneys", "Results", "Testimonials", "Free Consultation", "Contact"], +"Travel": ["Destinations", "Deals", "Book Now", "Reviews", "FAQ", "Contact Us"], +"Food & Beverage": ["Menu", "Locations", "Order Online", "Reservations", "Catering", "About Us"] +} + +# Get sitelinks for the specified industry, or use default +sitelink_types = industry_sitelinks.get(industry, ["About Us", "Services", "Products", "Contact Us", "Testimonials", "FAQ"]) + +# Generate sitelinks +sitelinks = [] +base_url = landing_page.rstrip('/') if landing_page else "" + +for sitelink_type in sitelink_types: +# Generate URL path based on sitelink type +path = sitelink_type.lower().replace(' ', '-') +url = f"{base_url}/{path}" if base_url else f"https://example.com/{path}" + +# Generate description based on sitelink type +description = "" +if sitelink_type == "About Us": +description = f"Learn more about {business_name} and our mission." +elif sitelink_type == "Services" or sitelink_type == "Products": +description = f"Explore our range of {primary_keywords[0] if primary_keywords else 'offerings'}." +elif sitelink_type == "Contact Us": +description = f"Get in touch with our team for assistance." +elif sitelink_type == "Testimonials" or sitelink_type == "Reviews": +description = f"See what our customers say about us." +elif sitelink_type == "FAQ": +description = f"Find answers to common questions." +elif sitelink_type == "Pricing" or sitelink_type == "Rates": +description = f"View our competitive pricing options." +elif sitelink_type == "Shop Now" or sitelink_type == "Order Online": +description = f"Browse and purchase our {primary_keywords[0] if primary_keywords else 'products'} online." + +# Add the sitelink +sitelinks.append({ +"text": sitelink_type, +"url": url, +"description": description +}) + +return sitelinks + +def generate_callouts(business_name: str, unique_selling_points: List[str], industry: str) -> List[str]: +""" +Generate callout extensions based on business information. + +Args: +business_name: Name of the business +unique_selling_points: List of unique selling points +industry: Industry of the business + +Returns: +List of callout texts +""" +# Use provided USPs if available +if unique_selling_points and len(unique_selling_points) >= 4: +# Ensure callouts are not too long (25 characters max) +callouts = [] +for usp in unique_selling_points: +if len(usp) <= 25: +callouts.append(usp) +else: +# Try to truncate at a space +truncated = usp[:22] + "..." +callouts.append(truncated) + +return callouts[:8] # Return up to 8 callouts + +# Define common callouts by industry +industry_callouts = { +"E-commerce": ["Free Shipping", "24/7 Customer Service", "Secure Checkout", "Easy Returns", "Price Match Guarantee", "Next Day Delivery", "Satisfaction Guaranteed", "Exclusive Deals"], +"SaaS/Technology": ["24/7 Support", "Free Trial", "No Credit Card Required", "Easy Integration", "Data Security", "Cloud-Based", "Regular Updates", "Customizable"], +"Healthcare": ["Board Certified", "Most Insurance Accepted", "Same-Day Appointments", "Compassionate Care", "State-of-the-Art Facility", "Experienced Staff", "Convenient Location", "Telehealth Available"], +"Education": ["Accredited Programs", "Expert Faculty", "Financial Aid", "Career Services", "Small Class Sizes", "Flexible Schedule", "Online Options", "Hands-On Learning"], +"Finance": ["FDIC Insured", "No Hidden Fees", "Personalized Service", "Online Banking", "Mobile App", "Low Interest Rates", "Financial Planning", "Retirement Services"], +"Real Estate": ["Free Home Valuation", "Virtual Tours", "Experienced Agents", "Local Expertise", "Financing Available", "Property Management", "Commercial & Residential", "Investment Properties"], +"Legal": ["Free Consultation", "No Win No Fee", "Experienced Attorneys", "24/7 Availability", "Proven Results", "Personalized Service", "Multiple Practice Areas", "Aggressive Representation"] +} + +# Get callouts for the specified industry, or use default +callouts = industry_callouts.get(industry, ["Professional Service", "Experienced Team", "Customer Satisfaction", "Quality Guaranteed", "Competitive Pricing", "Fast Service", "Personalized Solutions", "Trusted Provider"]) + +return callouts + +def generate_structured_snippets(business_name: str, business_description: str, industry: str, primary_keywords: List[str]) -> Dict: +""" +Generate structured snippet extensions based on business information. + +Args: +business_name: Name of the business +business_description: Description of the business +industry: Industry of the business +primary_keywords: List of primary keywords + +Returns: +Dictionary with structured snippet information +""" +# Define common snippet headers and values by industry +industry_snippets = { +"E-commerce": { +"header": "Brands", +"values": ["Nike", "Adidas", "Apple", "Samsung", "Sony", "LG", "Dell", "HP"] +}, +"SaaS/Technology": { +"header": "Services", +"values": ["Cloud Storage", "Data Analytics", "CRM", "Project Management", "Email Marketing", "Cybersecurity", "API Integration", "Automation"] +}, +"Healthcare": { +"header": "Services", +"values": ["Preventive Care", "Diagnostics", "Treatment", "Surgery", "Rehabilitation", "Counseling", "Telemedicine", "Wellness Programs"] +}, +"Education": { +"header": "Courses", +"values": ["Business", "Technology", "Healthcare", "Design", "Engineering", "Education", "Arts", "Sciences"] +}, +"Finance": { +"header": "Services", +"values": ["Checking Accounts", "Savings Accounts", "Loans", "Mortgages", "Investments", "Retirement Planning", "Insurance", "Wealth Management"] +}, +"Real Estate": { +"header": "Types", +"values": ["Single-Family Homes", "Condos", "Townhouses", "Apartments", "Commercial", "Land", "New Construction", "Luxury Homes"] +}, +"Legal": { +"header": "Services", +"values": ["Personal Injury", "Family Law", "Criminal Defense", "Estate Planning", "Business Law", "Immigration", "Real Estate Law", "Intellectual Property"] +} +} + +# Get snippets for the specified industry, or use default +snippet_info = industry_snippets.get(industry, { +"header": "Services", +"values": ["Consultation", "Assessment", "Implementation", "Support", "Maintenance", "Training", "Customization", "Analysis"] +}) + +# If we have primary keywords, try to incorporate them +if primary_keywords: +# Try to determine a better header based on keywords +service_keywords = ["service", "support", "consultation", "assistance", "help"] +product_keywords = ["product", "item", "good", "merchandise"] +brand_keywords = ["brand", "make", "manufacturer"] + +for kw in primary_keywords: +kw_lower = kw.lower() +if any(service_word in kw_lower for service_word in service_keywords): +snippet_info["header"] = "Services" +break +elif any(product_word in kw_lower for product_word in product_keywords): +snippet_info["header"] = "Products" +break +elif any(brand_word in kw_lower for brand_word in brand_keywords): +snippet_info["header"] = "Brands" +break + +return snippet_info + +def generate_custom_extensions(business_info: Dict, extension_type: str) -> Any: +""" +Generate custom extensions using AI based on business information. + +Args: +business_info: Dictionary with business information +extension_type: Type of extension to generate + +Returns: +Generated extension data +""" +# Extract business information +business_name = business_info.get("business_name", "") +business_description = business_info.get("business_description", "") +industry = business_info.get("industry", "") +primary_keywords = business_info.get("primary_keywords", []) +unique_selling_points = business_info.get("unique_selling_points", []) + +# Create a prompt based on extension type +if extension_type == "sitelinks": +prompt = f""" +Generate 6 sitelink extensions for a Google Ads campaign for the following business: + +Business Name: {business_name} +Business Description: {business_description} +Industry: {industry} +Keywords: {', '.join(primary_keywords)} + +For each sitelink, provide: +1. Link text (max 25 characters) +2. Description line 1 (max 35 characters) +3. Description line 2 (max 35 characters) + +Format the response as a JSON array of objects with "text", "description1", and "description2" fields. +""" +elif extension_type == "callouts": +prompt = f""" +Generate 8 callout extensions for a Google Ads campaign for the following business: + +Business Name: {business_name} +Business Description: {business_description} +Industry: {industry} +Keywords: {', '.join(primary_keywords)} +Unique Selling Points: {', '.join(unique_selling_points)} + +Each callout should: +1. Be 25 characters or less +2. Highlight a feature, benefit, or unique selling point +3. Be concise and impactful + +Format the response as a JSON array of strings. +""" +elif extension_type == "structured_snippets": +prompt = f""" +Generate structured snippet extensions for a Google Ads campaign for the following business: + +Business Name: {business_name} +Business Description: {business_description} +Industry: {industry} +Keywords: {', '.join(primary_keywords)} + +Provide: +1. The most appropriate header type (e.g., Brands, Services, Products, Courses, etc.) +2. 8 values that are relevant to the business (each 25 characters or less) + +Format the response as a JSON object with "header" and "values" fields. +""" +else: +return None + +# Generate the extensions using the LLM +try: +response = llm_text_gen(prompt) + +# Process the response based on extension type +# In a real implementation, you would parse the JSON response +# For this example, we'll return a placeholder + +if extension_type == "sitelinks": +return [ +{"text": "About Us", "description1": "Learn about our company", "description2": "Our history and mission"}, +{"text": "Services", "description1": "Explore our service offerings", "description2": "Solutions for your needs"}, +{"text": "Products", "description1": "Browse our product catalog", "description2": "Quality items at great prices"}, +{"text": "Contact Us", "description1": "Get in touch with our team", "description2": "We're here to help you"}, +{"text": "Testimonials", "description1": "See what customers say", "description2": "Real reviews from real people"}, +{"text": "FAQ", "description1": "Frequently asked questions", "description2": "Find quick answers here"} +] +elif extension_type == "callouts": +return ["Free Shipping", "24/7 Support", "Money-Back Guarantee", "Expert Team", "Premium Quality", "Fast Service", "Affordable Prices", "Satisfaction Guaranteed"] +elif extension_type == "structured_snippets": +return {"header": "Services", "values": ["Consultation", "Installation", "Maintenance", "Repair", "Training", "Support", "Design", "Analysis"]} +else: +return None + +except Exception as e: +print(f"Error generating extensions: {str(e)}") +return None \ No newline at end of file diff --git a/lib/ai_marketing_tools/ai_google_ads_generator/ad_templates.py b/lib/ai_marketing_tools/ai_google_ads_generator/ad_templates.py new file mode 100644 index 00000000..0e701fcf --- /dev/null +++ b/lib/ai_marketing_tools/ai_google_ads_generator/ad_templates.py @@ -0,0 +1,219 @@ +""" +Ad Templates Module + +This module provides templates for different ad types and industries. +""" + +from typing import Dict, List, Any + +def get_industry_templates(industry: str) -> Dict: +""" +Get ad templates specific to an industry. + +Args: +industry: The industry to get templates for + +Returns: +Dictionary with industry-specific templates +""" +# Define templates for different industries +templates = { +"E-commerce": { +"headline_templates": [ +"{product} - {benefit} | {business_name}", +"Shop {product} - {discount} Off Today", +"Top-Rated {product} - Free Shipping", +"{benefit} with Our {product}", +"New {product} Collection - {benefit}", +"{discount}% Off {product} - Limited Time", +"Buy {product} Online - Fast Delivery", +"{product} Sale Ends {timeframe}", +"Best-Selling {product} from {business_name}", +"Premium {product} - {benefit}" +], +"description_templates": [ +"Shop our selection of {product} and enjoy {benefit}. Free shipping on orders over ${amount}. Order now!", +"Looking for quality {product}? Get {benefit} with our {product}. {discount} off your first order!", +"{business_name} offers premium {product} with {benefit}. Shop online or visit our store today!", +"Discover our {product} collection. {benefit} guaranteed or your money back. Order now and save {discount}!" +], +"emotional_triggers": ["exclusive", "limited time", "sale", "discount", "free shipping", "bestseller", "new arrival"], +"call_to_actions": ["Shop Now", "Buy Today", "Order Online", "Get Yours", "Add to Cart", "Save Today"] +}, +"SaaS/Technology": { +"headline_templates": [ +"{product} Software - {benefit}", +"Try {product} Free for {timeframe}", +"{benefit} with Our {product} Platform", +"{product} - Rated #1 for {feature}", +"New {feature} in Our {product} Software", +"{business_name} - {benefit} Software", +"Streamline {pain_point} with {product}", +"{product} Software - {discount} Off", +"Enterprise-Grade {product} for {audience}", +"{product} - {benefit} Guaranteed" +], +"description_templates": [ +"{business_name}'s {product} helps you {benefit}. Try it free for {timeframe}. No credit card required.", +"Struggling with {pain_point}? Our {product} provides {benefit}. Join {number}+ satisfied customers.", +"Our {product} platform offers {feature} to help you {benefit}. Rated {rating}/5 by {source}.", +"{product} by {business_name}: {benefit} for your business. Plans starting at ${price}/month." +], +"emotional_triggers": ["efficient", "time-saving", "seamless", "integrated", "secure", "scalable", "innovative"], +"call_to_actions": ["Start Free Trial", "Request Demo", "Learn More", "Sign Up Free", "Get Started", "See Plans"] +}, +"Healthcare": { +"headline_templates": [ +"{service} in {location} | {business_name}", +"Expert {service} - {benefit}", +"Quality {service} for {audience}", +"{business_name} - {credential} {professionals}", +"Same-Day {service} Appointments", +"{service} Specialists in {location}", +"Affordable {service} - {benefit}", +"{symptom}? Get {service} Today", +"Advanced {service} Technology", +"Compassionate {service} Care" +], +"description_templates": [ +"{business_name} provides expert {service} with {benefit}. Our {credential} team is ready to help. Schedule today!", +"Experiencing {symptom}? Our {professionals} offer {service} with {benefit}. Most insurance accepted.", +"Quality {service} in {location}. {benefit} from our experienced team. Call now to schedule your appointment.", +"Our {service} center provides {benefit} for {audience}. Open {days} with convenient hours." +], +"emotional_triggers": ["trusted", "experienced", "compassionate", "advanced", "personalized", "comprehensive", "gentle"], +"call_to_actions": ["Schedule Now", "Book Appointment", "Call Today", "Free Consultation", "Learn More", "Find Relief"] +}, +"Real Estate": { +"headline_templates": [ +"{property_type} in {location} | {business_name}", +"{property_type} for {price_range} - {location}", +"Find Your Dream {property_type} in {location}", +"{feature} {property_type} - {location}", +"New {property_type} Listings in {location}", +"Sell Your {property_type} in {timeframe}", +"{business_name} - {credential} {professionals}", +"{property_type} {benefit} - {location}", +"Exclusive {property_type} Listings", +"{number}+ {property_type} Available Now" +], +"description_templates": [ +"Looking for {property_type} in {location}? {business_name} offers {benefit}. Browse our listings or call us today!", +"Sell your {property_type} in {location} with {business_name}. Our {professionals} provide {benefit}. Free valuation!", +"{business_name}: {credential} {professionals} helping you find the perfect {property_type} in {location}. Call now!", +"Discover {feature} {property_type} in {location}. Prices from {price_range}. Schedule a viewing today!" +], +"emotional_triggers": ["dream home", "exclusive", "luxury", "investment", "perfect location", "spacious", "modern"], +"call_to_actions": ["View Listings", "Schedule Viewing", "Free Valuation", "Call Now", "Learn More", "Get Pre-Approved"] +} +} + +# Return templates for the specified industry, or a default if not found +return templates.get(industry, { +"headline_templates": [ +"{product/service} - {benefit} | {business_name}", +"Professional {product/service} - {benefit}", +"{benefit} with Our {product/service}", +"{business_name} - {credential} {product/service}", +"Quality {product/service} for {audience}", +"Affordable {product/service} - {benefit}", +"{product/service} in {location}", +"{feature} {product/service} by {business_name}", +"Experienced {product/service} Provider", +"{product/service} - Satisfaction Guaranteed" +], +"description_templates": [ +"{business_name} offers professional {product/service} with {benefit}. Contact us today to learn more!", +"Looking for quality {product/service}? {business_name} provides {benefit}. Call now for more information.", +"Our {product/service} helps you {benefit}. Trusted by {number}+ customers. Contact us today!", +"{business_name}: {credential} {product/service} provider. We offer {benefit} for {audience}. Learn more!" +], +"emotional_triggers": ["professional", "quality", "trusted", "experienced", "affordable", "reliable", "satisfaction"], +"call_to_actions": ["Contact Us", "Learn More", "Call Now", "Get Quote", "Visit Website", "Schedule Consultation"] +}) + +def get_ad_type_templates(ad_type: str) -> Dict: +""" +Get templates specific to an ad type. + +Args: +ad_type: The ad type to get templates for + +Returns: +Dictionary with ad type-specific templates +""" +# Define templates for different ad types +templates = { +"Responsive Search Ad": { +"headline_count": 15, +"description_count": 4, +"headline_max_length": 30, +"description_max_length": 90, +"best_practices": [ +"Include at least 3 headlines with keywords", +"Create headlines with different lengths", +"Include at least 1 headline with a call to action", +"Include at least 1 headline with your brand name", +"Create descriptions that complement each other", +"Include keywords in at least 2 descriptions", +"Include a call to action in at least 1 description" +] +}, +"Expanded Text Ad": { +"headline_count": 3, +"description_count": 2, +"headline_max_length": 30, +"description_max_length": 90, +"best_practices": [ +"Include keywords in Headline 1", +"Use a call to action in Headline 2 or 3", +"Include your brand name in one headline", +"Make descriptions complementary but able to stand alone", +"Include keywords in at least one description", +"Include a call to action in at least one description" +] +}, +"Call-Only Ad": { +"headline_count": 2, +"description_count": 2, +"headline_max_length": 30, +"description_max_length": 90, +"best_practices": [ +"Focus on encouraging phone calls", +"Include language like 'Call now', 'Speak to an expert', etc.", +"Mention phone availability (e.g., '24/7', 'Available now')", +"Include benefits of calling rather than clicking", +"Be clear about who will answer the call", +"Include any special offers for callers" +] +}, +"Dynamic Search Ad": { +"headline_count": 0, # Headlines are dynamically generated +"description_count": 2, +"headline_max_length": 0, # N/A +"description_max_length": 90, +"best_practices": [ +"Create descriptions that work with any dynamically generated headline", +"Focus on your unique selling points", +"Include a strong call to action", +"Highlight benefits that apply across your product/service range", +"Avoid specific product mentions that might not match the dynamic headline" +] +} +} + +# Return templates for the specified ad type, or a default if not found +return templates.get(ad_type, { +"headline_count": 3, +"description_count": 2, +"headline_max_length": 30, +"description_max_length": 90, +"best_practices": [ +"Include keywords in headlines", +"Use a call to action", +"Include your brand name", +"Make descriptions informative and compelling", +"Include keywords in descriptions", +"Highlight unique selling points" +] +}) \ No newline at end of file diff --git a/lib/ai_marketing_tools/ai_google_ads_generator/google_ads_generator.py b/lib/ai_marketing_tools/ai_google_ads_generator/google_ads_generator.py new file mode 100644 index 00000000..4d408d55 --- /dev/null +++ b/lib/ai_marketing_tools/ai_google_ads_generator/google_ads_generator.py @@ -0,0 +1,1346 @@ +""" +Google Ads Generator Module + +This module provides a comprehensive UI for generating high-converting Google Ads +based on user inputs and best practices. +""" + +import streamlit as st +import pandas as pd +import time +import json +from datetime import datetime +import re +import random +from typing import Dict, List, Tuple, Any, Optional + +# Import internal modules +from ...gpt_providers.text_generation.main_text_generation import llm_text_gen +from .ad_analyzer import analyze_ad_quality, calculate_quality_score, analyze_keyword_relevance +from .ad_templates import get_industry_templates, get_ad_type_templates +from .ad_extensions_generator import generate_extensions + +def write_google_ads(): +"""Main function to render the Google Ads Generator UI.""" + +# Page title and description +st.title("🚀 AI Google Ads Generator") +st.markdown(""" +Create high-converting Google Ads that drive clicks and conversions. +Our AI-powered tool follows Google Ads best practices to help you maximize your ad spend ROI. +""") + +# Initialize session state for storing generated ads +if "generated_ads" not in st.session_state: +st.session_state.generated_ads = [] + +if "selected_ad_index" not in st.session_state: +st.session_state.selected_ad_index = None + +if "ad_history" not in st.session_state: +st.session_state.ad_history = [] + +# Create tabs for different sections +tabs = st.tabs(["Ad Creation", "Ad Performance", "Ad History", "Best Practices"]) + +with tabs[0]: +render_ad_creation_tab() + +with tabs[1]: +render_ad_performance_tab() + +with tabs[2]: +render_ad_history_tab() + +with tabs[3]: +render_best_practices_tab() + +def render_ad_creation_tab(): +"""Render the Ad Creation tab with all input fields.""" + +# Create columns for a better layout +col1, col2 = st.columns([2, 1]) + +with col1: +st.subheader("Campaign Details") + +# Business information +business_name = st.text_input( +"Business Name", +help="Enter your business or brand name" +) + +business_description = st.text_area( +"Business Description", +help="Briefly describe your business, products, or services (100-200 characters recommended)", +max_chars=500 +) + +# Industry selection +industries = [ +"E-commerce", "SaaS/Technology", "Healthcare", "Education", +"Finance", "Real Estate", "Legal", "Travel", "Food & Beverage", +"Fashion", "Beauty", "Fitness", "Home Services", "B2B Services", +"Entertainment", "Automotive", "Non-profit", "Other" +] + +industry = st.selectbox( +"Industry", +industries, +help="Select the industry that best matches your business" +) + +# Campaign objective +objectives = [ +"Sales", "Leads", "Website Traffic", "Brand Awareness", +"App Promotion", "Local Store Visits", "Product Consideration" +] + +campaign_objective = st.selectbox( +"Campaign Objective", +objectives, +help="What is the main goal of your advertising campaign?" +) + +# Target audience +target_audience = st.text_area( +"Target Audience", +help="Describe your ideal customer (age, interests, pain points, etc.)", +max_chars=300 +) + +# Create a container for the keyword section +keyword_container = st.container() + +with keyword_container: +st.subheader("Keywords & Targeting") + +# Primary keywords +primary_keywords = st.text_area( +"Primary Keywords (1 per line)", +help="Enter your main keywords (1-5 recommended). These will be prominently featured in your ads.", +height=100 +) + +# Secondary keywords +secondary_keywords = st.text_area( +"Secondary Keywords (1 per line)", +help="Enter additional relevant keywords that can be included when appropriate.", +height=100 +) + +# Negative keywords +negative_keywords = st.text_area( +"Negative Keywords (1 per line)", +help="Enter terms you want to avoid in your ads.", +height=100 +) + +# Match type selection +match_types = st.multiselect( +"Keyword Match Types", +["Broad Match", "Phrase Match", "Exact Match"], +default=["Phrase Match"], +help="Select the match types you want to use for your keywords" +) + +with col2: +st.subheader("Ad Specifications") + +# Ad type +ad_types = [ +"Responsive Search Ad", +"Expanded Text Ad", +"Call-Only Ad", +"Dynamic Search Ad" +] + +ad_type = st.selectbox( +"Ad Type", +ad_types, +help="Select the type of Google Ad you want to create" +) + +# Number of ad variations +num_variations = st.slider( +"Number of Ad Variations", +min_value=1, +max_value=5, +value=3, +help="Generate multiple ad variations for A/B testing" +) + +# Unique selling points +usp = st.text_area( +"Unique Selling Points (1 per line)", +help="What makes your product/service unique? (e.g., Free shipping, 24/7 support)", +height=100 +) + +# Call to action +cta_options = [ +"Shop Now", "Learn More", "Sign Up", "Get Started", +"Contact Us", "Book Now", "Download", "Request a Demo", +"Get a Quote", "Subscribe", "Join Now", "Apply Now", +"Custom" +] + +cta_selection = st.selectbox( +"Call to Action", +cta_options, +help="Select a primary call to action for your ads" +) + +if cta_selection == "Custom": +custom_cta = st.text_input( +"Custom Call to Action", +help="Enter your custom call to action (keep it short and action-oriented)" +) + +# Landing page URL +landing_page = st.text_input( +"Landing Page URL", +help="Enter the URL where users will be directed after clicking your ad" +) + +# Ad tone +tone_options = [ +"Professional", "Conversational", "Urgent", "Informative", +"Persuasive", "Empathetic", "Authoritative", "Friendly" +] + +ad_tone = st.selectbox( +"Ad Tone", +tone_options, +help="Select the tone of voice for your ads" +) + +# Ad Extensions section +st.subheader("Ad Extensions") +st.markdown("Ad extensions improve visibility and provide additional information to potential customers.") + +# Create columns for extension types +ext_col1, ext_col2 = st.columns(2) + +with ext_col1: +# Sitelink extensions +st.markdown("##### Sitelink Extensions") +num_sitelinks = st.slider("Number of Sitelinks", 0, 6, 4) + +sitelinks = [] +if num_sitelinks > 0: +for i in range(num_sitelinks): +col1, col2 = st.columns(2) +with col1: +link_text = st.text_input(f"Sitelink {i+1} Text", key=f"sitelink_text_{i}") +with col2: +link_url = st.text_input(f"Sitelink {i+1} URL", key=f"sitelink_url_{i}") + +link_desc = st.text_input( +f"Sitelink {i+1} Description (optional)", +key=f"sitelink_desc_{i}", +help="Optional: Add 1-2 description lines (max 35 chars each)" +) + +if link_text and link_url: +sitelinks.append({ +"text": link_text, +"url": link_url, +"description": link_desc +}) + +# Callout extensions +st.markdown("##### Callout Extensions") +callout_text = st.text_area( +"Callout Extensions (1 per line)", +help="Add short phrases highlighting your business features (e.g., '24/7 Customer Service')", +height=100 +) + +with ext_col2: +# Structured snippet extensions +st.markdown("##### Structured Snippet Extensions") +snippet_headers = [ +"Brands", "Courses", "Degree Programs", "Destinations", +"Featured Hotels", "Insurance Coverage", "Models", +"Neighborhoods", "Service Catalog", "Services", +"Shows", "Styles", "Types" +] + +snippet_header = st.selectbox("Snippet Header", snippet_header_options) +snippet_values = st.text_area( +"Snippet Values (1 per line)", +help="Add values related to the selected header (e.g., for 'Services': 'Cleaning', 'Repairs')", +height=100 +) + +# Call extensions +st.markdown("##### Call Extension") +include_call = st.checkbox("Include Call Extension") +if include_call: +phone_number = st.text_input("Phone Number") + +# Advanced options in an expander +with st.expander("Advanced Options"): +col1, col2 = st.columns(2) + +with col1: +# Device preference +device_preference = st.multiselect( +"Device Preference", +["Mobile", "Desktop", "Tablet"], +default=["Mobile", "Desktop"], +help="Select which devices to optimize ads for" +) + +# Location targeting +location_targeting = st.text_input( +"Location Targeting", +help="Enter locations to target (e.g., 'New York, Los Angeles')" +) + +with col2: +# Competitor analysis +competitor_urls = st.text_area( +"Competitor URLs (1 per line)", +help="Enter URLs of competitors for analysis (optional)", +height=100 +) + +# Budget information +daily_budget = st.number_input( +"Daily Budget ($)", +min_value=1.0, +value=50.0, +help="Enter your daily budget for this campaign" +) + +# Generate button +if st.button("Generate Google Ads", type="primary"): +if not business_name or not business_description or not primary_keywords: +st.error("Please fill in the required fields: Business Name, Business Description, and Primary Keywords.") +return + +with st.spinner("Generating high-converting Google Ads..."): +# Process keywords +primary_kw_list = [kw.strip() for kw in primary_keywords.split("\n") if kw.strip()] +secondary_kw_list = [kw.strip() for kw in secondary_keywords.split("\n") if kw.strip()] +negative_kw_list = [kw.strip() for kw in negative_keywords.split("\n") if kw.strip()] + +# Process USPs +usp_list = [point.strip() for point in usp.split("\n") if point.strip()] + +# Process callouts +callout_list = [callout.strip() for callout in callout_text.split("\n") if callout.strip()] + +# Process snippets +snippet_list = [snippet.strip() for snippet in snippet_values.split("\n") if snippet.strip()] + +# Get the CTA +final_cta = custom_cta if cta_selection == "Custom" else cta_selection + +# Generate ads +generated_ads = generate_google_ads( +business_name=business_name, +business_description=business_description, +industry=industry, +campaign_objective=campaign_objective, +target_audience=target_audience, +primary_keywords=primary_kw_list, +secondary_keywords=secondary_kw_list, +negative_keywords=negative_kw_list, +match_types=match_types, +ad_type=ad_type, +num_variations=num_variations, +unique_selling_points=usp_list, +call_to_action=final_cta, +landing_page=landing_page, +ad_tone=ad_tone, +sitelinks=sitelinks, +callouts=callout_list, +snippet_header=snippet_header, +snippet_values=snippet_list, +phone_number=phone_number if include_call else None, +device_preference=device_preference, +location_targeting=location_targeting, +competitor_urls=[url.strip() for url in competitor_urls.split("\n") if url.strip()], +daily_budget=daily_budget +) + +if generated_ads: +# Store the generated ads in session state +st.session_state.generated_ads = generated_ads + +# Add to history +st.session_state.ad_history.append({ +"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), +"business_name": business_name, +"industry": industry, +"campaign_objective": campaign_objective, +"ads": generated_ads +}) + +# Display the generated ads +display_generated_ads(generated_ads) +else: +st.error("Failed to generate ads. Please try again with different inputs.") + +def generate_google_ads(**kwargs) -> List[Dict]: +""" +Generate Google Ads based on user inputs. + +Args: +**kwargs: All the user inputs from the form + +Returns: +List of dictionaries containing generated ads and their metadata +""" +# Extract key parameters +business_name = kwargs.get("business_name", "") +business_description = kwargs.get("business_description", "") +industry = kwargs.get("industry", "") +campaign_objective = kwargs.get("campaign_objective", "") +target_audience = kwargs.get("target_audience", "") +primary_keywords = kwargs.get("primary_keywords", []) +secondary_keywords = kwargs.get("secondary_keywords", []) +negative_keywords = kwargs.get("negative_keywords", []) +ad_type = kwargs.get("ad_type", "Responsive Search Ad") +num_variations = kwargs.get("num_variations", 3) +unique_selling_points = kwargs.get("unique_selling_points", []) +call_to_action = kwargs.get("call_to_action", "Learn More") +landing_page = kwargs.get("landing_page", "") +ad_tone = kwargs.get("ad_tone", "Professional") + +# Get templates based on industry and ad type +industry_templates = get_industry_templates(industry) +ad_type_templates = get_ad_type_templates(ad_type) + +# Prepare the prompt for the LLM +system_prompt = """You are an expert Google Ads copywriter with years of experience creating high-converting ads. +Your task is to create Google Ads that follow best practices, maximize Quality Score, and drive high CTR and conversion rates. + +For each ad, provide: +1. Headlines (3-15 depending on ad type) +2. Descriptions (2-4 depending on ad type) +3. Display URL path (2 fields) +4. A brief explanation of why this ad would be effective + +Format your response as valid JSON with the following structure for each ad: +{ +"headlines": ["Headline 1", "Headline 2", ...], +"descriptions": ["Description 1", "Description 2", ...], +"path1": "path-one", +"path2": "path-two", +"explanation": "Brief explanation of the ad's strengths" +} + +IMPORTANT GUIDELINES: +- Include primary keywords in headlines and descriptions +- Ensure headlines are 30 characters or less +- Ensure descriptions are 90 characters or less +- Include the call to action in at least one headline or description +- Make the ad relevant to the search intent +- Highlight unique selling points +- Use emotional triggers appropriate for the industry +- Ensure the ad is compliant with Google Ads policies +- Create distinct variations that test different approaches +""" + +prompt = f""" +Create {num_variations} high-converting Google {ad_type}s for the following business: + +BUSINESS INFORMATION: +Business Name: {business_name} +Business Description: {business_description} +Industry: {industry} +Campaign Objective: {campaign_objective} +Target Audience: {target_audience} +Landing Page: {landing_page} + +KEYWORDS: +Primary Keywords: {', '.join(primary_keywords)} +Secondary Keywords: {', '.join(secondary_keywords)} +Negative Keywords: {', '.join(negative_keywords)} + +UNIQUE SELLING POINTS: +{', '.join(unique_selling_points)} + +SPECIFICATIONS: +Ad Type: {ad_type} +Call to Action: {call_to_action} +Tone: {ad_tone} + +ADDITIONAL INSTRUCTIONS: +- For Responsive Search Ads, create 10-15 headlines and 2-4 descriptions +- For Expanded Text Ads, create 3 headlines and 2 descriptions +- For Call-Only Ads, focus on encouraging calls +- For Dynamic Search Ads, create compelling descriptions that work with dynamically generated headlines +- Include at least one headline with the primary keyword +- Include the call to action in at least one headline and one description +- Ensure all headlines are 30 characters or less +- Ensure all descriptions are 90 characters or less +- Use the business name in at least one headline +- Create distinct variations that test different approaches and angles +- Format the response as a valid JSON array of ad objects + +Return ONLY the JSON array with no additional text or explanation. +""" + +try: +# Generate the ads using the LLM +response = llm_text_gen(prompt, system_prompt=system_prompt) + +# Parse the JSON response +try: +# Try to parse the response as JSON +ads_data = json.loads(response) + +# If the response is not a list, wrap it in a list +if not isinstance(ads_data, list): +ads_data = [ads_data] + +# Process each ad +processed_ads = [] +for i, ad in enumerate(ads_data): +# Analyze the ad quality +quality_analysis = analyze_ad_quality( +ad, +primary_keywords, +secondary_keywords, +business_name, +call_to_action +) + +# Calculate quality score +quality_score = calculate_quality_score( +ad, +primary_keywords, +landing_page, +ad_type +) + +# Add metadata to the ad +processed_ad = { +"id": f"ad_{int(time.time())}_{i}", +"type": ad_type, +"headlines": ad.get("headlines", []), +"descriptions": ad.get("descriptions", []), +"path1": ad.get("path1", ""), +"path2": ad.get("path2", ""), +"final_url": landing_page, +"business_name": business_name, +"primary_keywords": primary_keywords, +"quality_analysis": quality_analysis, +"quality_score": quality_score, +"explanation": ad.get("explanation", ""), +"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") +} + +processed_ads.append(processed_ad) + +return processed_ads + +except json.JSONDecodeError: +# If JSON parsing fails, try to extract structured data from the text +st.warning("Failed to parse JSON response. Attempting to extract structured data from text.") + +# Implement fallback parsing logic here +# This is a simplified example - you would need more robust parsing +headlines_pattern = r"Headlines?:(.*?)Descriptions?:" +descriptions_pattern = r"Descriptions?:(.*?)(?:Path|Display URL|$)" + +ads_data = [] +variations = re.split(r"Ad Variation \d+:|Ad \d+:", response) + +for variation in variations: +if not variation.strip(): +continue + +headlines_match = re.search(headlines_pattern, variation, re.DOTALL) +descriptions_match = re.search(descriptions_pattern, variation, re.DOTALL) + +if headlines_match and descriptions_match: +headlines = [h.strip() for h in re.findall(r'"([^"]*)"', headlines_match.group(1))] +descriptions = [d.strip() for d in re.findall(r'"([^"]*)"', descriptions_match.group(1))] + +if not headlines: +headlines = [h.strip() for h in re.findall(r'- (.*)', headlines_match.group(1))] + +if not descriptions: +descriptions = [d.strip() for d in re.findall(r'- (.*)', descriptions_match.group(1))] + +ads_data.append({ +"headlines": headlines, +"descriptions": descriptions, +"path1": f"{primary_keywords[0].lower().replace(' ', '-')}" if primary_keywords else "", +"path2": "info", +"explanation": "Generated from text response" +}) + +# Process each ad as before +processed_ads = [] +for i, ad in enumerate(ads_data): +quality_analysis = analyze_ad_quality( +ad, +primary_keywords, +secondary_keywords, +business_name, +call_to_action +) + +quality_score = calculate_quality_score( +ad, +primary_keywords, +landing_page, +ad_type +) + +processed_ad = { +"id": f"ad_{int(time.time())}_{i}", +"type": ad_type, +"headlines": ad.get("headlines", []), +"descriptions": ad.get("descriptions", []), +"path1": ad.get("path1", ""), +"path2": ad.get("path2", ""), +"final_url": landing_page, +"business_name": business_name, +"primary_keywords": primary_keywords, +"quality_analysis": quality_analysis, +"quality_score": quality_score, +"explanation": ad.get("explanation", ""), +"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") +} + +processed_ads.append(processed_ad) + +return processed_ads + +except Exception as e: +st.error(f"Error generating ads: {str(e)}") +return [] + +def display_generated_ads(ads: List[Dict]): +""" +Display the generated ads in a user-friendly format. + +Args: +ads: List of dictionaries containing generated ads and their metadata +""" +st.subheader("Generated Google Ads") +st.write(f"Generated {len(ads)} ad variations. Click on each ad to see details.") + +# Create tabs for different views +ad_tabs = st.tabs(["Preview", "Performance Analysis", "Export"]) + +with ad_tabs[0]: +# Display each ad in an expander +for i, ad in enumerate(ads): +ad_type = ad.get("type", "Google Ad") +quality_score = ad.get("quality_score", {}).get("overall_score", 0) + +# Create a color based on quality score +if quality_score >= 8: +quality_color = "green" +elif quality_score >= 6: +quality_color = "orange" +else: +quality_color = "red" + +with st.expander(f"Ad Variation {i+1} - Quality Score: {quality_score}/10", expanded=(i==0)): +# Create columns for preview and details +col1, col2 = st.columns([3, 2]) + +with col1: +# Display ad preview +st.markdown("### Ad Preview") + +# Display headlines +for j, headline in enumerate(ad.get("headlines", [])[:3]): # Show first 3 headlines +st.markdown(f"**{headline}**") + +# Display URL +display_url = f"{ad.get('final_url', '').replace('https://', '').replace('http://', '').split('/')[0]}/{ad.get('path1', '')}/{ad.get('path2', '')}" +st.markdown(f"{display_url}", unsafe_allow_html=True) + +# Display descriptions +for description in ad.get("descriptions", []): +st.markdown(f"{description}") + +# Display explanation +if ad.get("explanation"): +st.markdown("#### Why this ad works:") +st.markdown(f"_{ad.get('explanation')}_") + +with col2: +# Display quality analysis +st.markdown("### Quality Analysis") + +quality_analysis = ad.get("quality_analysis", {}) +quality_score_details = ad.get("quality_score", {}) + +# Display quality score +st.markdown(f"**Overall Quality Score:** {quality_score}/10", unsafe_allow_html=True) + +# Display individual metrics +metrics = [ +("Keyword Relevance", quality_score_details.get("keyword_relevance", 0)), +("Ad Relevance", quality_score_details.get("ad_relevance", 0)), +("CTA Effectiveness", quality_score_details.get("cta_effectiveness", 0)), +("Landing Page Relevance", quality_score_details.get("landing_page_relevance", 0)) +] + +for metric_name, metric_score in metrics: +if metric_score >= 8: +metric_color = "green" +elif metric_score >= 6: +metric_color = "orange" +else: +metric_color = "red" + +st.markdown(f"**{metric_name}:** {metric_score}/10", unsafe_allow_html=True) + +# Display strengths and improvements +if quality_analysis.get("strengths"): +st.markdown("#### Strengths:") +for strength in quality_analysis.get("strengths", []): +st.markdown(f"✅ {strength}") + +if quality_analysis.get("improvements"): +st.markdown("#### Improvement Opportunities:") +for improvement in quality_analysis.get("improvements", []): +st.markdown(f"🔍 {improvement}") + +# Add buttons for actions +col1, col2, col3 = st.columns(3) + +with col1: +if st.button("Select This Ad", key=f"select_ad_{i}"): +st.session_state.selected_ad_index = i +st.success(f"Ad Variation {i+1} selected!") + +with col2: +if st.button("Edit This Ad", key=f"edit_ad_{i}"): +# This would open an editing interface +st.info("Ad editing feature coming soon!") + +with col3: +if st.button("Generate Similar", key=f"similar_ad_{i}"): +st.info("Similar ad generation feature coming soon!") + +with ad_tabs[1]: +# Display performance analysis +st.subheader("Ad Performance Analysis") + +# Create a DataFrame for comparison +comparison_data = [] +for i, ad in enumerate(ads): +quality_score = ad.get("quality_score", {}) + +comparison_data.append({ +"Ad Variation": f"Ad {i+1}", +"Overall Score": quality_score.get("overall_score", 0), +"Keyword Relevance": quality_score.get("keyword_relevance", 0), +"Ad Relevance": quality_score.get("ad_relevance", 0), +"CTA Effectiveness": quality_score.get("cta_effectiveness", 0), +"Landing Page Relevance": quality_score.get("landing_page_relevance", 0), +"Est. CTR": f"{quality_score.get('estimated_ctr', 0):.2f}%", +"Est. Conv. Rate": f"{quality_score.get('estimated_conversion_rate', 0):.2f}%" +}) + +# Create a DataFrame and display it +df = pd.DataFrame(comparison_data) +st.dataframe(df, use_container_width=True) + +# Display a bar chart comparing overall scores +st.subheader("Quality Score Comparison") +chart_data = pd.DataFrame({ +"Ad Variation": [f"Ad {i+1}" for i in range(len(ads))], +"Overall Score": [ad.get("quality_score", {}).get("overall_score", 0) for ad in ads] +}) + +st.bar_chart(chart_data, x="Ad Variation", y="Overall Score", use_container_width=True) + +# Display keyword analysis +st.subheader("Keyword Analysis") + +if ads and len(ads) > 0: +# Get the primary keywords from the first ad +primary_keywords = ads[0].get("primary_keywords", []) + +# Analyze keyword usage across all ads +keyword_data = [] +for keyword in primary_keywords: +keyword_data.append({ +"Keyword": keyword, +"Headline Usage": sum(1 for ad in ads if any(keyword.lower() in headline.lower() for headline in ad.get("headlines", []))), +"Description Usage": sum(1 for ad in ads if any(keyword.lower() in desc.lower() for desc in ad.get("descriptions", []))), +"Path Usage": sum(1 for ad in ads if keyword.lower() in ad.get("path1", "").lower() or keyword.lower() in ad.get("path2", "").lower()) +}) + +# Create a DataFrame and display it +kw_df = pd.DataFrame(keyword_data) +st.dataframe(kw_df, use_container_width=True) + +with ad_tabs[2]: +# Export options +st.subheader("Export Options") + +# Select export format +export_format = st.selectbox( +"Export Format", +["CSV", "Excel", "Google Ads Editor CSV", "JSON"] +) + +# Select which ads to export +export_selection = st.radio( +"Export Selection", +["All Generated Ads", "Selected Ad Only", "Ads Above Quality Score Threshold"] +) + +if export_selection == "Ads Above Quality Score Threshold": +quality_threshold = st.slider("Minimum Quality Score", 1, 10, 7) + +# Export button +if st.button("Export Ads", type="primary"): +# Determine which ads to export +if export_selection == "All Generated Ads": +ads_to_export = ads +elif export_selection == "Selected Ad Only": +if st.session_state.selected_ad_index is not None: +ads_to_export = [ads[st.session_state.selected_ad_index]] +else: +st.warning("Please select an ad first.") +ads_to_export = [] +else: # Above threshold +ads_to_export = [ad for ad in ads if ad.get("quality_score", {}).get("overall_score", 0) >= quality_threshold] + +if ads_to_export: +# Prepare the export data based on format +if export_format == "CSV" or export_format == "Google Ads Editor CSV": +# Create CSV data +if export_format == "CSV": +# Simple CSV format +export_data = [] +for ad in ads_to_export: +export_data.append({ +"Ad Type": ad.get("type", ""), +"Headlines": " | ".join(ad.get("headlines", [])), +"Descriptions": " | ".join(ad.get("descriptions", [])), +"Path 1": ad.get("path1", ""), +"Path 2": ad.get("path2", ""), +"Final URL": ad.get("final_url", ""), +"Quality Score": ad.get("quality_score", {}).get("overall_score", 0) +}) +else: +# Google Ads Editor format +export_data = [] +for ad in ads_to_export: +base_row = { +"Action": "Add", +"Campaign": "", # User would fill this in +"Ad Group": "", # User would fill this in +"Status": "Enabled", +"Final URL": ad.get("final_url", ""), +"Path 1": ad.get("path1", ""), +"Path 2": ad.get("path2", "") +} + +# Add headlines and descriptions based on ad type +if ad.get("type") == "Responsive Search Ad": +for i, headline in enumerate(ad.get("headlines", []), 1): +base_row[f"Headline {i}"] = headline + +for i, desc in enumerate(ad.get("descriptions", []), 1): +base_row[f"Description {i}"] = desc +else: +# For other ad types +for i, headline in enumerate(ad.get("headlines", [])[:3], 1): +base_row[f"Headline {i}"] = headline + +for i, desc in enumerate(ad.get("descriptions", [])[:2], 1): +base_row[f"Description {i}"] = desc + +export_data.append(base_row) + +# Convert to DataFrame and then to CSV +df = pd.DataFrame(export_data) +csv = df.to_csv(index=False) + +# Create a download button +st.download_button( +label="Download CSV", +data=csv, +file_name=f"google_ads_export_{int(time.time())}.csv", +mime="text/csv" +) + +elif export_format == "Excel": +# Create Excel data +export_data = [] +for ad in ads_to_export: +export_data.append({ +"Ad Type": ad.get("type", ""), +"Headlines": " | ".join(ad.get("headlines", [])), +"Descriptions": " | ".join(ad.get("descriptions", [])), +"Path 1": ad.get("path1", ""), +"Path 2": ad.get("path2", ""), +"Final URL": ad.get("final_url", ""), +"Quality Score": ad.get("quality_score", {}).get("overall_score", 0) +}) + +# Convert to DataFrame and then to Excel +df = pd.DataFrame(export_data) + +# Create a temporary Excel file +excel_file = f"google_ads_export_{int(time.time())}.xlsx" +df.to_excel(excel_file, index=False) + +# Read the file and create a download button +with open(excel_file, "rb") as f: +st.download_button( +label="Download Excel", +data=f, +file_name=excel_file, +mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +) + +else: # JSON +# Convert to JSON +json_data = json.dumps(ads_to_export, indent=2) + +# Create a download button +st.download_button( +label="Download JSON", +data=json_data, +file_name=f"google_ads_export_{int(time.time())}.json", +mime="application/json" +) +else: +st.warning("No ads to export based on your selection.") + +def render_ad_performance_tab(): +"""Render the Ad Performance tab with analytics and insights.""" + +st.subheader("Ad Performance Simulator") +st.write("Simulate how your ads might perform based on industry benchmarks and our predictive model.") + +# Check if we have generated ads +if not st.session_state.generated_ads: +st.info("Generate ads first to see performance predictions.") +return + +# Get the selected ad or the first one +selected_index = st.session_state.selected_ad_index if st.session_state.selected_ad_index is not None else 0 + +if selected_index >= len(st.session_state.generated_ads): +selected_index = 0 + +selected_ad = st.session_state.generated_ads[selected_index] + +# Display the selected ad +st.markdown(f"### Selected Ad (Variation {selected_index + 1})") + +# Create columns for the ad preview +col1, col2 = st.columns([3, 2]) + +with col1: +# Display headlines +for headline in selected_ad.get("headlines", [])[:3]: +st.markdown(f"**{headline}**") + +# Display URL +display_url = f"{selected_ad.get('final_url', '').replace('https://', '').replace('http://', '').split('/')[0]}/{selected_ad.get('path1', '')}/{selected_ad.get('path2', '')}" +st.markdown(f"{display_url}", unsafe_allow_html=True) + +# Display descriptions +for description in selected_ad.get("descriptions", []): +st.markdown(f"{description}") + +with col2: +# Display quality score +quality_score = selected_ad.get("quality_score", {}).get("overall_score", 0) + +# Create a color based on quality score +if quality_score >= 8: +quality_color = "green" +elif quality_score >= 6: +quality_color = "orange" +else: +quality_color = "red" + +st.markdown(f"**Quality Score:** {quality_score}/10", unsafe_allow_html=True) + +# Display estimated metrics +est_ctr = selected_ad.get("quality_score", {}).get("estimated_ctr", 0) +est_conv_rate = selected_ad.get("quality_score", {}).get("estimated_conversion_rate", 0) + +st.markdown(f"**Estimated CTR:** {est_ctr:.2f}%") +st.markdown(f"**Estimated Conversion Rate:** {est_conv_rate:.2f}%") + +# Performance simulation +st.subheader("Performance Simulation") + +# Create columns for inputs +col1, col2, col3 = st.columns(3) + +with col1: +daily_budget = st.number_input("Daily Budget ($)", min_value=1.0, value=50.0) +cost_per_click = st.number_input("Average CPC ($)", min_value=0.1, value=1.5, step=0.1) + +with col2: +avg_conversion_value = st.number_input("Avg. Conversion Value ($)", min_value=0.0, value=50.0) +time_period = st.selectbox("Time Period", ["Day", "Week", "Month"]) + +with col3: +# Use the estimated CTR and conversion rate from the ad quality score +ctr_override = st.number_input("CTR Override (%)", min_value=0.1, max_value=100.0, value=est_ctr, step=0.1) +conv_rate_override = st.number_input("Conversion Rate Override (%)", min_value=0.01, max_value=100.0, value=est_conv_rate, step=0.01) + +# Calculate performance metrics +if time_period == "Day": +multiplier = 1 +elif time_period == "Week": +multiplier = 7 +else: # Month +multiplier = 30 + +total_budget = daily_budget * multiplier +clicks = total_budget / cost_per_click +impressions = clicks * 100 / ctr_override +conversions = clicks * conv_rate_override / 100 +conversion_value = conversions * avg_conversion_value +roi = ((conversion_value - total_budget) / total_budget) * 100 if total_budget > 0 else 0 + +# Display the results +st.subheader(f"Projected {time_period} Performance") + +# Create columns for metrics +col1, col2, col3, col4 = st.columns(4) + +with col1: +st.metric("Impressions", f"{impressions:,.0f}") +st.metric("Clicks", f"{clicks:,.0f}") + +with col2: +st.metric("CTR", f"{ctr_override:.2f}%") +st.metric("Cost", f"${total_budget:,.2f}") + +with col3: +st.metric("Conversions", f"{conversions:,.2f}") +st.metric("Conversion Rate", f"{conv_rate_override:.2f}%") + +with col4: +st.metric("Conversion Value", f"${conversion_value:,.2f}") +st.metric("ROI", f"{roi:,.2f}%") + +# Display a chart +st.subheader("Performance Over Time") + +# Create data for the chart +chart_data = pd.DataFrame({ +"Day": list(range(1, multiplier + 1)), +"Clicks": [clicks / multiplier] * multiplier, +"Conversions": [conversions / multiplier] * multiplier, +"Cost": [daily_budget] * multiplier, +"Value": [conversion_value / multiplier] * multiplier +}) + +# Add some random variation to make the chart more realistic +for i in range(len(chart_data)): +variation_factor = 0.9 + (random.random() * 0.2) # Between 0.9 and 1.1 +chart_data.loc[i, "Clicks"] *= variation_factor +chart_data.loc[i, "Conversions"] *= variation_factor +chart_data.loc[i, "Value"] *= variation_factor + +# Calculate cumulative metrics +chart_data["Cumulative Clicks"] = chart_data["Clicks"].cumsum() +chart_data["Cumulative Conversions"] = chart_data["Conversions"].cumsum() +chart_data["Cumulative Cost"] = chart_data["Cost"].cumsum() +chart_data["Cumulative Value"] = chart_data["Value"].cumsum() +chart_data["Cumulative ROI"] = ((chart_data["Cumulative Value"] - chart_data["Cumulative Cost"]) / chart_data["Cumulative Cost"]) * 100 + +# Display the chart +st.line_chart(chart_data.set_index("Day")[["Cumulative Clicks", "Cumulative Conversions"]]) + +# Display ROI chart +st.subheader("ROI Over Time") +st.line_chart(chart_data.set_index("Day")["Cumulative ROI"]) + +# Optimization recommendations +st.subheader("Optimization Recommendations") + +# Generate recommendations based on the ad and performance metrics +recommendations = [] + +# Check if CTR is low +if ctr_override < 2.0: +recommendations.append({ +"title": "Improve Click-Through Rate", +"description": "Your estimated CTR is below average. Consider testing more compelling headlines and stronger calls to action.", +"impact": "High" +}) + +# Check if conversion rate is low +if conv_rate_override < 3.0: +recommendations.append({ +"title": "Enhance Landing Page Experience", +"description": "Your conversion rate could be improved. Ensure your landing page is relevant to your ad and provides a clear path to conversion.", +"impact": "High" +}) + +# Check if ROI is low +if roi < 100: +recommendations.append({ +"title": "Optimize for Higher ROI", +"description": "Your ROI is below target. Consider increasing your conversion value or reducing your cost per click.", +"impact": "Medium" +}) + +# Check keyword usage +quality_analysis = selected_ad.get("quality_analysis", {}) +if quality_analysis.get("improvements"): +for improvement in quality_analysis.get("improvements"): +if "keyword" in improvement.lower(): +recommendations.append({ +"title": "Improve Keyword Relevance", +"description": improvement, +"impact": "Medium" +}) + +# Add general recommendations +recommendations.append({ +"title": "Test Multiple Ad Variations", +"description": "Continue testing different ad variations to identify the best performing combination of headlines and descriptions.", +"impact": "Medium" +}) + +recommendations.append({ +"title": "Add Ad Extensions", +"description": "Enhance your ad with sitelinks, callouts, and structured snippets to increase visibility and provide additional information.", +"impact": "Medium" +}) + +# Display recommendations +for i, rec in enumerate(recommendations): +with st.expander(f"{rec['title']} (Impact: {rec['impact']})", expanded=(i==0)): +st.write(rec["description"]) + +def render_ad_history_tab(): +"""Render the Ad History tab with previously generated ads.""" + +st.subheader("Ad History") +st.write("View and manage your previously generated ads.") + +# Check if we have any history +if not st.session_state.ad_history: +st.info("No ad history yet. Generate some ads to see them here.") +return + +# Display the history in reverse chronological order +for i, history_item in enumerate(reversed(st.session_state.ad_history)): +with st.expander(f"{history_item['timestamp']} - {history_item['business_name']} ({history_item['industry']})", expanded=(i==0)): +# Display basic info +st.write(f"**Campaign Objective:** {history_item['campaign_objective']}") +st.write(f"**Number of Ads:** {len(history_item['ads'])}") + +# Add a button to view the ads +if st.button("View These Ads", key=f"view_history_{i}"): +# Set the current ads to these historical ads +st.session_state.generated_ads = history_item['ads'] +st.success("Loaded ads from history. Go to the Ad Creation tab to view them.") + +# Add a button to delete from history +if st.button("Delete from History", key=f"delete_history_{i}"): +# Remove this item from history +index_to_remove = len(st.session_state.ad_history) - 1 - i +if 0 <= index_to_remove < len(st.session_state.ad_history): +st.session_state.ad_history.pop(index_to_remove) +st.success("Removed from history.") +st.rerun() + +def render_best_practices_tab(): +"""Render the Best Practices tab with Google Ads guidelines and tips.""" + +st.subheader("Google Ads Best Practices") +st.write("Follow these guidelines to create high-performing Google Ads campaigns.") + +# Create tabs for different best practice categories +bp_tabs = st.tabs(["Ad Copy", "Keywords", "Landing Pages", "Quality Score", "Extensions"]) + +with bp_tabs[0]: +st.markdown(""" +### Ad Copy Best Practices + +#### Headlines +- **Include Primary Keywords**: Place your main keyword in at least one headline +- **Highlight Benefits**: Focus on what the user gains, not just features +- **Use Numbers and Stats**: Specific numbers increase credibility and CTR +- **Create Urgency**: Words like "now," "today," or "limited time" drive action +- **Ask Questions**: Engage users with relevant questions +- **Keep It Short**: Aim for 25-30 characters for better display across devices + +#### Descriptions +- **Expand on Headlines**: Provide more details about your offer +- **Include Secondary Keywords**: Incorporate additional relevant keywords +- **Add Specific CTAs**: Tell users exactly what action to take +- **Address Pain Points**: Show how you solve the user's problems +- **Include Proof**: Mention testimonials, reviews, or guarantees +- **Use All Available Space**: Aim for 85-90 characters per description + +#### Display Path +- **Include Keywords**: Add relevant keywords to your display path +- **Create Clarity**: Use paths that indicate where users will land +- **Be Specific**: Use product categories or service types +""") + +st.info(""" +**Pro Tip**: Create at least 5 headlines and 4 descriptions for Responsive Search Ads to give Google's algorithm more options to optimize performance. +""") + +with bp_tabs[1]: +st.markdown(""" +### Keyword Best Practices + +#### Keyword Selection +- **Use Specific Keywords**: More specific keywords typically have higher conversion rates +- **Include Long-Tail Keywords**: These often have less competition and lower CPCs +- **Group by Intent**: Separate keywords by search intent (informational, commercial, transactional) +- **Consider Competitor Keywords**: Include competitor brand terms if your budget allows +- **Use Location Keywords**: Add location-specific terms for local businesses + +#### Match Types +- **Broad Match Modified**: Use for wider reach with some control +- **Phrase Match**: Good balance between reach and relevance +- **Exact Match**: Highest relevance but limited reach +- **Use a Mix**: Implement a tiered approach with different match types + +#### Negative Keywords +- **Add Irrelevant Terms**: Exclude searches that aren't relevant to your business +- **Filter Out Window Shoppers**: Exclude terms like "free," "cheap," or "DIY" if you're selling premium services +- **Regularly Review Search Terms**: Add new negative keywords based on actual searches +- **Use Negative Keyword Lists**: Create reusable lists for common exclusions +""") + +st.info(""" +**Pro Tip**: Start with phrase and exact match keywords, then use the Search Terms report to identify new keyword opportunities and negative keywords. +""") + +with bp_tabs[2]: +st.markdown(""" +### Landing Page Best Practices + +#### Relevance +- **Match Ad Copy**: Ensure your landing page content aligns with your ad +- **Use Same Keywords**: Include the same keywords from your ad in your landing page +- **Fulfill the Promise**: Deliver what your ad offered +- **Clear Value Proposition**: Communicate your unique value immediately + +#### User Experience +- **Fast Loading Speed**: Optimize for quick loading (under 3 seconds) +- **Mobile Optimization**: Ensure perfect display on all devices +- **Clear Navigation**: Make it easy for users to find what they need +- **Minimal Distractions**: Remove unnecessary elements that don't support conversion + +#### Conversion Optimization +- **Prominent CTA**: Make your call-to-action button stand out +- **Reduce Form Fields**: Ask for only essential information +- **Add Trust Signals**: Include testimonials, reviews, and security badges +- **A/B Test**: Continuously test different landing page elements +""") + +st.info(""" +**Pro Tip**: Create dedicated landing pages for each ad group rather than sending all traffic to your homepage for higher conversion rates. +""") + +with bp_tabs[3]: +st.markdown(""" +### Quality Score Optimization + +#### What Affects Quality Score +- **Click-Through Rate (CTR)**: The most important factor +- **Ad Relevance**: How closely your ad matches the search intent +- **Landing Page Experience**: Relevance, transparency, and navigation +- **Expected Impact**: Google's prediction of how your ad will perform + +#### Improving Quality Score +- **Tightly Themed Ad Groups**: Create small, focused ad groups with related keywords +- **Relevant Ad Copy**: Ensure your ads directly address the search query +- **Optimize Landing Pages**: Create specific landing pages for each ad group +- **Improve CTR**: Test different ad variations to find what drives the highest CTR +- **Use Ad Extensions**: Extensions improve visibility and relevance + +#### Benefits of High Quality Score +- **Lower Costs**: Higher quality scores can reduce your CPC +- **Better Ad Positions**: Improved rank in the auction +- **Higher ROI**: Better performance for the same budget +""") + +st.info(""" +**Pro Tip**: A 1-point improvement in Quality Score can reduce your CPC by up to 16% according to industry studies. +""") + +with bp_tabs[4]: +st.markdown(""" +### Ad Extensions Best Practices + +#### Sitelink Extensions +- **Use Descriptive Text**: Clearly explain where each link leads +- **Create Unique Links**: Each sitelink should go to a different landing page +- **Include 6+ Sitelinks**: Give Google options to show the most relevant ones +- **Add Descriptions**: Two description lines provide more context + +#### Callout Extensions +- **Highlight Benefits**: Focus on unique selling points +- **Keep It Short**: 12-15 characters is optimal +- **Add 8+ Callouts**: Give Google plenty of options +- **Be Specific**: "24/7 Customer Support" is better than "Great Service" + +#### Structured Snippet Extensions +- **Choose Relevant Headers**: Select the most applicable category +- **Add Comprehensive Values**: Include all relevant options +- **Be Concise**: Keep each value short and clear +- **Create Multiple Snippets**: Different headers for different ad groups + +#### Other Extensions +- **Call Extensions**: Add your phone number for call-focused campaigns +- **Location Extensions**: Link your Google Business Profile +- **Price Extensions**: Showcase products or services with prices +- **App Extensions**: Promote your mobile app +- **Lead Form Extensions**: Collect leads directly from your ad +""") + +st.info(""" +**Pro Tip**: Ad extensions are free to add and can significantly increase your ad's CTR by providing additional information and increasing your ad's size on the search results page. +""") + +# Additional resources +st.subheader("Additional Resources") + +col1, col2, col3 = st.columns(3) + +with col1: +st.markdown(""" +#### Google Resources +- [Google Ads Help Center](https://support.google.com/google-ads/) +- [Google Ads Best Practices](https://support.google.com/google-ads/topic/3119143) +- [Google Ads Academy](https://skillshop.withgoogle.com/google-ads) +""") + +with col2: +st.markdown(""" +#### Tools +- [Google Keyword Planner](https://ads.google.com/home/tools/keyword-planner/) +- [Google Ads Editor](https://ads.google.com/home/tools/ads-editor/) +- [Google Ads Preview Tool](https://ads.google.com/aw/tools/ad-preview) +""") + +with col3: +st.markdown(""" +#### Learning Resources +- [Google Ads Certification](https://skillshop.withgoogle.com/google-ads) +- [Google Ads YouTube Channel](https://www.youtube.com/user/learnwithgoogle) +- [Google Ads Blog](https://blog.google/products/ads/) +""") + +if __name__ == "__main__": +write_google_ads() \ No newline at end of file diff --git a/lib/ai_writers/ai_blog_faqs_writer/faqs_generator_blog.py b/lib/ai_writers/ai_blog_faqs_writer/faqs_generator_blog.py index 8a8f8ea6..cefab088 100644 --- a/lib/ai_writers/ai_blog_faqs_writer/faqs_generator_blog.py +++ b/lib/ai_writers/ai_blog_faqs_writer/faqs_generator_blog.py @@ -7,6 +7,7 @@ well-researched FAQs from various content sources with customizable options. import sys import json +import re from typing import Dict, List, Optional, Union from pathlib import Path from enum import Enum @@ -15,12 +16,12 @@ from loguru import logger from lib.gpt_providers.text_generation.main_text_generation import llm_text_gen from lib.ai_web_researcher.google_serp_search import google_search -from lib.ai_web_researcher.tavily_ai_search import tavily_search +from lib.ai_web_researcher.tavily_ai_search import do_tavily_ai_search from lib.ai_web_researcher.metaphor_basic_neural_web_search import metaphor_search_articles logger.remove() logger.add(sys.stdout, - colorize=True, + colorize=True, format="{level}|{file}:{line}:{function}| {message}") class TargetAudience(Enum): @@ -51,6 +52,7 @@ class FAQConfig: time_range: str = "last_6_months" exclude_domains: List[str] = None language: str = "English" + selected_search_queries: List[str] = None @dataclass class FAQItem: @@ -71,26 +73,77 @@ class FAQGenerator: self.config = config or FAQConfig() self.faqs: List[FAQItem] = [] self.research_results = {} + self.search_queries = [] - async def generate_faqs(self, content: str, content_type: str = "general") -> List[FAQItem]: + def generate_search_queries(self, content: str) -> List[str]: + """Generate search queries based on the content.""" + try: + prompt = f"""Based on the following content, generate 5 specific search queries that would help create comprehensive FAQs. + Content: {content} + + Guidelines for search queries: + 1. Focus on key concepts and terms + 2. Include common questions users might have + 3. Cover technical aspects that need clarification + 4. Include best practices and recommendations + 5. Make queries specific and focused + + Please provide exactly 5 search queries, one per line. + Do not include numbers or bullet points in the queries. + """ + + response = llm_text_gen(prompt) + # Clean up the queries by removing numbers and extra spaces + queries = [] + for line in response.split('\n'): + # Remove any leading numbers, dots, or spaces + cleaned = re.sub(r'^\d+\.\s*', '', line.strip()) + if cleaned: + queries.append(cleaned) + + self.search_queries = queries[:5] # Ensure we only get 5 queries + return self.search_queries + + except Exception as err: + logger.error(f"Failed to generate search queries: {err}") + return [] + + def _clean_search_query(self, query: str) -> str: + """Clean up a search query by removing numbers and extra formatting.""" + # Remove any leading numbers, dots, or spaces + cleaned = re.sub(r'^\d+\.\s*', '', query.strip()) + # Remove any quotes + cleaned = cleaned.replace('"', '').replace("'", '') + # Remove any extra spaces + cleaned = ' '.join(cleaned.split()) + return cleaned + + def generate_faqs(self, content: str, content_type: str = "general") -> List[FAQItem]: """Generate FAQs from the given content with research integration.""" try: - # Step 1: Research the topic - research_results = await self._conduct_research(content) + if not self.config.selected_search_queries: + raise ValueError("No search queries selected. Please select queries to proceed.") + + # Clean up selected queries + cleaned_queries = [self._clean_search_query(q) for q in self.config.selected_search_queries] + self.config.selected_search_queries = cleaned_queries + + # Step 1: Research the topic using selected queries + research_results = self._conduct_research(content) # Step 2: Generate initial FAQs - initial_faqs = await self._generate_initial_faqs(content, research_results) + initial_faqs = self._generate_initial_faqs(content, research_results) # Step 3: Enhance FAQs with research - enhanced_faqs = await self._enhance_faqs_with_research(initial_faqs, research_results) + enhanced_faqs = self._enhance_faqs_with_research(initial_faqs, research_results) # Step 4: Add code examples if requested if self.config.include_code_examples: - enhanced_faqs = await self._add_code_examples(enhanced_faqs) + enhanced_faqs = self._add_code_examples(enhanced_faqs) # Step 5: Add references if requested if self.config.include_references: - enhanced_faqs = await self._add_references(enhanced_faqs, research_results) + enhanced_faqs = self._add_references(enhanced_faqs, research_results) self.faqs = enhanced_faqs return enhanced_faqs @@ -99,38 +152,34 @@ class FAQGenerator: logger.error(f"Failed to generate FAQs: {err}") raise - async def _conduct_research(self, content: str) -> Dict: - """Conduct online research based on the content.""" + def _conduct_research(self, content: str) -> Dict: + """Conduct online research based on the selected search queries.""" try: - research_prompt = f"""Based on the following content, identify key topics and questions for research: - {content} - - Please provide a list of research topics and questions that would help create comprehensive FAQs. - Focus on: - 1. Key concepts and terms - 2. Common questions users might have - 3. Technical aspects that need clarification - 4. Best practices and recommendations - """ - - research_topics = await llm_text_gen(research_prompt) - - # Conduct research for each topic research_results = {} - for topic in research_topics.split('\n'): - if topic.strip(): + + for query in self.config.selected_search_queries: + try: + # Clean the query before searching + cleaned_query = self._clean_search_query(query) + logger.info(f"Researching query: {cleaned_query}") + # Select search function based on search depth if self.config.search_depth == SearchDepth.BASIC: - results = await google_search(topic.strip()) + results = google_search(cleaned_query) elif self.config.search_depth == SearchDepth.COMPREHENSIVE: - results = await tavily_search(topic.strip()) + results = do_tavily_ai_search(cleaned_query) elif self.config.search_depth == SearchDepth.EXPERT: - results = await metaphor_search_articles(topic.strip()) + results = metaphor_search_articles(cleaned_query) else: logger.warning(f"Unknown search depth: {self.config.search_depth}, defaulting to Google search") - results = await google_search(topic.strip()) + results = google_search(cleaned_query) - research_results[topic.strip()] = results + research_results[query] = results + logger.info(f"Research completed for query: {query}") + + except Exception as err: + logger.error(f"Failed to research query '{query}': {err}") + continue return research_results @@ -138,7 +187,7 @@ class FAQGenerator: logger.error(f"Failed to conduct research: {err}") return {} - async def _generate_initial_faqs(self, content: str, research_results: Dict) -> List[FAQItem]: + def _generate_initial_faqs(self, content: str, research_results: Dict) -> List[FAQItem]: """Generate initial FAQs using LLM.""" try: system_prompt = f"""You are an expert FAQ generator with deep knowledge in content creation and technical writing. @@ -159,6 +208,13 @@ class FAQGenerator: - Based on the provided research - Relevant to the target audience - Written in the specified style + + Format each FAQ exactly as follows: + Q: [Your question here] + A: [Your detailed answer here] + Category: [Category name] + Confidence: [Score between 0 and 1] + --- """ prompt = f"""Content to generate FAQs from: @@ -168,22 +224,26 @@ class FAQGenerator: {json.dumps(research_results, indent=2)} Please generate {self.config.num_faqs} FAQs following the guidelines above. - Format each FAQ with: - - Question - - Detailed answer - - Category - - Confidence score (0-1) + Each FAQ must be separated by '---' and include all required fields. """ - response = await llm_text_gen(prompt, system_prompt=system_prompt) + response = llm_text_gen(prompt, system_prompt=system_prompt) + logger.info(f"LLM Response: {response}") # Parse the response into FAQItem objects faqs = [] current_faq = None for line in response.split('\n'): + line = line.strip() + if not line or line == '---': + if current_faq and current_faq.question and current_faq.answer: + faqs.append(current_faq) + current_faq = None + continue + if line.startswith('Q:'): - if current_faq: + if current_faq and current_faq.question and current_faq.answer: faqs.append(current_faq) current_faq = FAQItem(question=line[2:].strip(), answer="", category="") elif line.startswith('A:'): @@ -194,18 +254,23 @@ class FAQGenerator: current_faq.category = line[9:].strip() elif line.startswith('Confidence:'): if current_faq: - current_faq.confidence_score = float(line[11:].strip()) + try: + current_faq.confidence_score = float(line[11:].strip()) + except ValueError: + current_faq.confidence_score = 0.5 - if current_faq: + # Add the last FAQ if it exists and is complete + if current_faq and current_faq.question and current_faq.answer: faqs.append(current_faq) + logger.info(f"Generated {len(faqs)} FAQs") return faqs except Exception as err: logger.error(f"Failed to generate initial FAQs: {err}") raise - async def _enhance_faqs_with_research(self, faqs: List[FAQItem], research_results: Dict) -> List[FAQItem]: + def _enhance_faqs_with_research(self, faqs: List[FAQItem], research_results: Dict) -> List[FAQItem]: """Enhance FAQs with research findings.""" try: enhanced_faqs = [] @@ -231,7 +296,7 @@ class FAQGenerator: 4. Keeping the answer concise and clear """ - enhanced_answer = await llm_text_gen(enhancement_prompt) + enhanced_answer = llm_text_gen(enhancement_prompt) faq.answer = enhanced_answer enhanced_faqs.append(faq) @@ -242,24 +307,20 @@ class FAQGenerator: logger.error(f"Failed to enhance FAQs with research: {err}") return faqs - async def _add_code_examples(self, faqs: List[FAQItem]) -> List[FAQItem]: + def _add_code_examples(self, faqs: List[FAQItem]) -> List[FAQItem]: """Add code examples to FAQs where applicable.""" try: for faq in faqs: if self._is_technical_question(faq.question): code_prompt = f"""Generate a code example for the following FAQ: - Question: {faq.question} Answer: {faq.answer} - Please provide a relevant code example that: - 1. Illustrates the answer clearly - 2. Includes comments and explanations - 3. Follows best practices - 4. Is easy to understand + Please provide a relevant code example that demonstrates the concept. + Include comments and explanations where necessary. """ - code_example = await llm_text_gen(code_prompt) + code_example = llm_text_gen(code_prompt) faq.code_example = code_example return faqs @@ -268,21 +329,19 @@ class FAQGenerator: logger.error(f"Failed to add code examples: {err}") return faqs - async def _add_references(self, faqs: List[FAQItem], research_results: Dict) -> List[FAQItem]: - """Add references to FAQs.""" + def _add_references(self, faqs: List[FAQItem], research_results: Dict) -> List[FAQItem]: + """Add references to FAQs based on research results.""" try: for faq in faqs: relevant_research = self._find_relevant_research(faq, research_results) if relevant_research: - faq.references = [ - { - "title": ref.get("title", ""), - "url": ref.get("url", ""), - "source": ref.get("source", ""), - "date": ref.get("date", "") - } - for ref in relevant_research.get("references", []) - ] + references = [] + for source, content in relevant_research.items(): + references.append({ + "source": source, + "content": content + }) + faq.references = references return faqs @@ -291,8 +350,7 @@ class FAQGenerator: return faqs def _find_relevant_research(self, faq: FAQItem, research_results: Dict) -> Dict: - """Find research relevant to a specific FAQ.""" - # Simple keyword matching for now - can be enhanced with semantic search + """Find research results relevant to a specific FAQ.""" relevant_research = {} for topic, results in research_results.items(): if any(keyword in faq.question.lower() for keyword in topic.lower().split()): @@ -308,8 +366,8 @@ class FAQGenerator: """Convert FAQs to markdown format.""" markdown = "# Frequently Asked Questions\n\n" - for i, faq in enumerate(self.faqs, 1): - markdown += f"## {i}. {faq.question}\n\n" + for faq in self.faqs: + markdown += f"## {faq.question}\n\n" markdown += f"{faq.answer}\n\n" if faq.code_example: @@ -320,7 +378,7 @@ class FAQGenerator: if faq.references: markdown += "### References\n" for ref in faq.references: - markdown += f"- [{ref['title']}]({ref['url']}) - {ref['source']} ({ref['date']})\n" + markdown += f"- {ref['source']}\n" markdown += "\n" return markdown @@ -333,52 +391,52 @@ class FAQGenerator: Frequently Asked Questions -
-

Frequently Asked Questions

+

Frequently Asked Questions

""" - for i, faq in enumerate(self.faqs, 1): + for faq in self.faqs: html += f""" -
-
{i}. {faq.question}
-
{faq.answer}
+
+
{faq.question}
+
{faq.answer}
""" if faq.code_example: html += f""" -
{faq.code_example}
+
+
{faq.code_example}
+
""" if faq.references: html += """ -
-

References

-
    +
    +

    References

    +
      """ for ref in faq.references: html += f""" -
    • {ref['title']} - {ref['source']} ({ref['date']})
    • +
    • {ref['source']}
    • """ html += """ -
    -
    +
+
""" html += """ -
+
""" html += """ -
""" diff --git a/lib/ai_writers/ai_blog_faqs_writer/faqs_ui.py b/lib/ai_writers/ai_blog_faqs_writer/faqs_ui.py index 720cb91b..ab938dcf 100644 --- a/lib/ai_writers/ai_blog_faqs_writer/faqs_ui.py +++ b/lib/ai_writers/ai_blog_faqs_writer/faqs_ui.py @@ -5,15 +5,27 @@ This module provides a user-friendly interface for generating FAQs from various """ import streamlit as st -import asyncio from pathlib import Path from typing import Optional import json import requests from bs4 import BeautifulSoup +import logging +import pyperclip from .faqs_generator_blog import FAQGenerator, FAQConfig, TargetAudience, FAQStyle, SearchDepth +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def copy_to_clipboard(text: str) -> None: + """Copy text to clipboard and show success message.""" + try: + pyperclip.copy(text) + st.success("Copied to clipboard!") + except Exception as e: + st.error(f"Failed to copy to clipboard: {str(e)}") def fetch_url_content(url): """Fetch and extract content from a URL.""" @@ -42,15 +54,27 @@ def fetch_url_content(url): return None def main(): - st.set_page_config( - page_title="FAQ Generator", - page_icon="❓", - layout="wide" - ) - st.title("FAQ Generator") st.markdown("Generate comprehensive FAQs from your content with research integration.") + # Initialize session state variables if they don't exist + if 'search_queries' not in st.session_state: + st.session_state.search_queries = [] + if 'selected_queries' not in st.session_state: + st.session_state.selected_queries = [] + if 'research_completed' not in st.session_state: + st.session_state.research_completed = False + if 'research_results' not in st.session_state: + st.session_state.research_results = {} + if 'faq_config' not in st.session_state: + st.session_state.faq_config = None + if 'generator' not in st.session_state: + st.session_state.generator = FAQGenerator() + if 'generated_faqs' not in st.session_state: + st.session_state.generated_faqs = None + if 'output_format' not in st.session_state: + st.session_state.output_format = "Preview" + # Sidebar for configuration with st.sidebar: st.header("Configuration") @@ -99,40 +123,137 @@ def main(): if content: st.text_area("Extracted Content", content, height=300) - # Generate button - if st.button("Generate FAQs") and content: - try: - # Create config - config = FAQConfig( - num_faqs=num_faqs, - target_audience=TargetAudience(target_audience), - faq_style=FAQStyle(faq_style), - include_emojis=include_emojis, - include_code_examples=include_code_examples, - include_references=include_references, - search_depth=SearchDepth(search_depth), - time_range=time_range, - language=language - ) - - # Initialize generator - generator = FAQGenerator(config) - - # Generate FAQs - with st.spinner("Generating FAQs..."): - faqs = asyncio.run(generator.generate_faqs(content)) - - # Display results - st.success("FAQs generated successfully!") + # Step 1: Generate search queries + if content and not st.session_state.search_queries: + if st.button("Generate Search Queries"): + with st.spinner("Generating search queries..."): + search_queries = st.session_state.generator.generate_search_queries(content) + if search_queries: + st.session_state.search_queries = search_queries + st.session_state.selected_queries = [] # Reset selected queries + st.session_state.research_completed = False # Reset research status + st.session_state.research_results = {} # Reset research results + st.session_state.faq_config = None # Reset config + st.session_state.generated_faqs = None # Reset generated FAQs + st.success("Search queries generated successfully!") + + # Step 2: Display and select search queries + if st.session_state.search_queries: + st.subheader("Select Search Queries") + st.info("Select the queries you want to use for web research. You can select multiple queries.") + + # Create checkboxes for each search query + selected_queries = [] + for query in st.session_state.search_queries: + if st.checkbox(query, key=f"query_{query}", value=query in st.session_state.selected_queries): + selected_queries.append(query) + + # Update selected queries in session state + st.session_state.selected_queries = selected_queries + + # Step 3: Do web research + if st.session_state.selected_queries and not st.session_state.research_completed: + if st.button("Do Web Research"): + try: + # Create config with selected queries + config = FAQConfig( + num_faqs=num_faqs, + target_audience=TargetAudience(target_audience), + faq_style=FAQStyle(faq_style), + include_emojis=include_emojis, + include_code_examples=include_code_examples, + include_references=include_references, + search_depth=SearchDepth(search_depth), + time_range=time_range, + language=language, + selected_search_queries=selected_queries + ) + + # Store config in session state + st.session_state.faq_config = config + + # Update generator with config + st.session_state.generator.config = config + + # Do research + with st.spinner("Conducting web research..."): + research_results = st.session_state.generator._conduct_research(content) + st.session_state.research_completed = True + st.session_state.research_results = research_results + st.success("Web research completed successfully!") + + # Display research results + st.subheader("Research Results") + for query, results in research_results.items(): + with st.expander(f"Results for: {query}"): + if isinstance(results, dict): + st.json(results) + else: + st.text(results) + + except Exception as e: + st.error(f"Error during web research: {str(e)}") + st.error("Please try again with different search queries or adjust the search depth.") + + # Step 4: Generate FAQs + if st.session_state.research_completed and st.session_state.research_results and st.session_state.faq_config: + if st.button("Generate FAQs"): + try: + # Update generator with stored config + st.session_state.generator.config = st.session_state.faq_config + + # Generate FAQs + with st.spinner("Generating FAQs..."): + logger.info("Starting FAQ generation...") + faqs = st.session_state.generator.generate_faqs(content) + logger.info(f"Generated {len(faqs) if faqs else 0} FAQs") + + if not faqs: + st.error("No FAQs were generated. Please try again.") + return + + st.session_state.generated_faqs = faqs + st.success("FAQs generated successfully!") + + except Exception as e: + logger.error(f"Error generating FAQs: {str(e)}") + st.error(f"Error generating FAQs: {str(e)}") + st.error("Please try again or adjust your settings.") + + # Display generated FAQs if they exist + if st.session_state.generated_faqs: + st.subheader("Generated FAQs") # Output format selection output_format = st.radio( "Output Format", - ["Preview", "Markdown", "HTML", "JSON"] + ["Preview", "Markdown", "HTML", "JSON"], + key="output_format" ) + # Create columns for copy and download buttons + col1, col2 = st.columns(2) + if output_format == "Preview": - for i, faq in enumerate(faqs, 1): + # Create a formatted text for copying + preview_text = "" + for i, faq in enumerate(st.session_state.generated_faqs, 1): + preview_text += f"{i}. {faq.question}\n" + preview_text += f"{faq.answer}\n\n" + if faq.code_example: + preview_text += f"Code Example:\n{faq.code_example}\n\n" + if faq.references: + preview_text += "References:\n" + for ref in faq.references: + preview_text += f"- {ref['source']}\n" + preview_text += "\n" + + with col1: + if st.button("Copy to Clipboard", key="copy_preview"): + copy_to_clipboard(preview_text) + + # Display the FAQs + for i, faq in enumerate(st.session_state.generated_faqs, 1): with st.expander(f"{i}. {faq.question}"): st.markdown(faq.answer) if faq.code_example: @@ -140,38 +261,52 @@ def main(): if faq.references: st.markdown("**References:**") for ref in faq.references: - st.markdown(f"- [{ref['title']}]({ref['url']}) - {ref['source']} ({ref['date']})") + st.markdown(f"- {ref['source']}") elif output_format == "Markdown": - st.code(generator.to_markdown(), language="markdown") - st.download_button( - "Download Markdown", - generator.to_markdown(), - file_name="faqs.md", - mime="text/markdown" - ) + markdown_output = st.session_state.generator.to_markdown() + st.code(markdown_output, language="markdown") + + with col1: + if st.button("Copy to Clipboard", key="copy_markdown"): + copy_to_clipboard(markdown_output) + with col2: + st.download_button( + "Download Markdown", + markdown_output, + file_name="faqs.md", + mime="text/markdown" + ) elif output_format == "HTML": - st.code(generator.to_html(), language="html") - st.download_button( - "Download HTML", - generator.to_html(), - file_name="faqs.html", - mime="text/html" - ) + html_output = st.session_state.generator.to_html() + st.code(html_output, language="html") + + with col1: + if st.button("Copy to Clipboard", key="copy_html"): + copy_to_clipboard(html_output) + with col2: + st.download_button( + "Download HTML", + html_output, + file_name="faqs.html", + mime="text/html" + ) elif output_format == "JSON": - json_output = json.dumps([faq.__dict__ for faq in faqs], indent=2) + json_output = json.dumps([faq.__dict__ for faq in st.session_state.generated_faqs], indent=2) st.code(json_output, language="json") - st.download_button( - "Download JSON", - json_output, - file_name="faqs.json", - mime="application/json" - ) - - except Exception as e: - st.error(f"Error generating FAQs: {str(e)}") + + with col1: + if st.button("Copy to Clipboard", key="copy_json"): + copy_to_clipboard(json_output) + with col2: + st.download_button( + "Download JSON", + json_output, + file_name="faqs.json", + mime="application/json" + ) if __name__ == "__main__": main() \ No newline at end of file diff --git a/lib/ai_writers/ai_letter_writer/business_letters.py b/lib/ai_writers/ai_letter_writer/business_letters.py new file mode 100644 index 00000000..baf2b9da --- /dev/null +++ b/lib/ai_writers/ai_letter_writer/business_letters.py @@ -0,0 +1,1271 @@ +""" +Business Letters Module + +This module provides a Streamlit interface for generating various types of business letters +using AI assistance. It collects user inputs specific to the chosen business 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 business letters.""" + # Basic HTML structure with inline styles for preview + formatted_paragraphs = "".join(f"

{p.strip()}

" for p in content.split("\n\n") if p.strip()) + return f""" +
+
{metadata.get('date', 'Date')}
+
Subject: {metadata.get('subject', 'No Subject')}
+
{metadata.get('salutation', 'Dear Recipient,')}
+
{formatted_paragraphs if formatted_paragraphs else "

Letter content goes here...

"}
+
{metadata.get('complimentary_close', 'Sincerely,')}
+
{metadata.get('sender_name', 'Sender Name')}
+
{metadata.get('sender_title', 'Sender Title')}
+
+ """ + +def analyze_letter_tone(content: str) -> Dict[str, float]: + """Placeholder: Returns dummy tone analysis.""" + # Returns scores between 0.0 and 1.0 + return {"professional": 0.9, "persuasive": 0.75, "confident": 0.8} + +def check_formality(content: str) -> float: + """Placeholder: Returns a dummy formality score (0.0 to 1.0).""" + return 0.85 # Example: 85% 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": 50.0, # Dummy score for business letters + "reading_level": "Fairly Difficult", # 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 "jargon" in content.lower(): + return ["Suggestion: Avoid excessive jargon unless appropriate for the recipient."] + elif "passive voice" in content.lower(): + return ["Suggestion: Consider using more active voice for clarity and impact."] + else: + return ["Suggestion: Ensure your call to action is clear and prominent."] + +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": ["Introduction", "Body", "Call to Action", "Closing"], "guidance": "Follow standard business letter practices."} + +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"Subject: Generated Business Letter Preview\n\nDear [Generated Recipient Name],\n\nThis is a sample business 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, persuasion level, length, and formality.]\n\nSincerely,\n[Generated Your Name]" + +# --- End Placeholder Functions --- + + +def write_letter(): + """ + Main function for the Business Letters interface. Sets up the Streamlit page + and handles navigation between subtype selection and the letter form. + """ + + # Page title and description + st.title("💼 Business Letter Writer") + st.markdown(""" + Create professional business letters for various purposes. Select a letter type below to get started. + """) + + # Initialize Streamlit session state variables specific to the business module. + # These variables persist across reruns and store the user's progress and data. + if "business_letter_subtype" not in st.session_state: + st.session_state.business_letter_subtype = None # Stores the ID of the selected business letter type + if "business_letter_generated" not in st.session_state: + st.session_state.business_letter_generated = False # Flag to indicate if a letter has been generated + if "business_letter_content" not in st.session_state: + st.session_state.business_letter_content = None # Stores the generated letter content + if "business_letter_metadata" not in st.session_state: + st.session_state.business_letter_metadata = {} # Stores metadata like sender/recipient info + if "business_letter_form_data" not in st.session_state: + st.session_state.business_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.business_letter_subtype is not None: + if st.button("← Back to Business 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.business_letter_subtype = None + st.session_state.business_letter_generated = False + st.session_state.business_letter_content = None + st.session_state.business_letter_metadata = {} + st.session_state.business_letter_form_data = {} + st.rerun() # Rerun the app to update the UI based on the changed state + + # Main navigation logic within the business module. + # If no subtype is selected, show the selection grid. Otherwise, show the form for the selected subtype. + if st.session_state.business_letter_subtype is None: + # Display business letter type selection if no subtype is selected + display_business_letter_types() + else: + # Display the interface form for the selected business letter subtype + display_business_letter_form(st.session_state.business_letter_subtype) + + +def display_business_letter_types(): + """ + Displays the business letter type selection interface using a grid of styled buttons. + Each button represents a specific type of business letter the user can choose to write. + """ + + st.markdown("## Select Business Letter Type") + + # Define business letter types with their details (ID, Name, Icon, Description, Color) + # This list is used to generate the selection buttons. + business_letter_types = [ + { + "id": "sales", + "name": "Sales Letter", + "icon": "💰", + "description": "Promote products or services to potential customers", + "color": "#4CAF50" # Green + }, + { + "id": "proposal", + "name": "Business Proposal", + "icon": "📊", + "description": "Present a business idea or solution", + "color": "#2196F3" # Blue + }, + { + "id": "order", + "name": "Order Letter", + "icon": "🛒", + "description": "Place an order for products or services", + "color": "#FF9800" # Orange + }, + { + "id": "quotation", + "name": "Quotation Letter", + "icon": "💲", + "description": "Provide pricing information for products or services", + "color": "#9C27B0" # Purple + }, + { + "id": "acknowledgment", + "name": "Acknowledgment Letter", + "icon": "✅", + "description": "Confirm receipt of payment, order, or documents", + "color": "#607D8B" # Blue Grey + }, + { + "id": "collection", + "name": "Collection Letter", + "icon": "💵", + "description": "Request payment for overdue accounts", + "color": "#F44336" # Red + }, + { + "id": "adjustment", + "name": "Adjustment Letter", + "icon": "🔧", + "description": "Respond to customer complaints or requests", + "color": "#795548" # Brown + }, + { + "id": "credit", + "name": "Credit Letter", + "icon": "💳", + "description": "Extend credit to customers or respond to credit requests", + "color": "#009688" # Teal + }, + { + "id": "follow_up", + "name": "Follow-up Letter", + "icon": "🔄", + "description": "Follow up on previous communication or meetings", + "color": "#673AB7" # Deep Purple + } + ] + + # Inject custom CSS to style the Streamlit buttons to look like cards. + # This provides a visually appealing selection grid. + st.markdown(""" + + """, 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(business_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

{letter_type_config['description']}

", + key=f"btn_business_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.business_letter_subtype = letter_type_config['id'] + # Clear previous data related to letter generation when selecting a new type + st.session_state.business_letter_generated = False + st.session_state.business_letter_content = None + st.session_state.business_letter_metadata = {} + st.session_state.business_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 business_letter_types: + button_styles += f""" + div.stButton > button[data-testid="stButton"][kind="primary"][sf-key*="btn_business_select_{letter_type_config['id']}"] {{ + background-color: {letter_type_config['color']}; + }} + div.stButton > button[data-testid="stButton"][kind="primary"][sf-key*="btn_business_select_{letter_type_config['id']}"]:hover {{ + background-color: {letter_type_config['color']}D9; /* Slightly darker on hover */ + }} + """ + st.markdown(f"", unsafe_allow_html=True) + + +def display_business_letter_form(subtype: str): + """ + Displays the form for the selected business letter subtype. This includes + input fields specific to the subtype, general business information fields, + tone and style options, and tabs for previewing and analyzing the generated letter. + + Args: + subtype: The ID string of the selected business 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("business", 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"business_letter_form_{subtype}"): + # Create tabs to organize the form sections. + tab1, tab2, tab3 = st.tabs(["Letter Details", "Business Information", "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.business_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.business_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']}") + elif field["type"] == "number": + # Use the default value from session state or the field config's min value + default_number_value = st.session_state.business_letter_form_data.get(field["id"], field.get("min", 0)) + form_data[field["id"]] = st.number_input(field["label"], min_value=field.get("min", 0), value=default_number_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 = get_tones_for_subtype(subtype) + default_tone = st.session_state.business_letter_form_data.get("tone", tone_options[0] if tone_options else "Professional") + form_data["tone"] = st.selectbox( + "Tone", + tone_options, + index=tone_options.index(default_tone) if default_tone in tone_options else 0, # Set index based on default value + help="Select the overall tone for your letter (e.g., Friendly, Formal, Assertive).", + key=f"{subtype}_tone" + ) + + # Slider for Persuasion Level, using session state default. + persuasion_options = ["Subtle", "Moderate", "Strong"] + default_persuasion = st.session_state.business_letter_form_data.get("persuasion_level", "Moderate") + form_data["persuasion_level"] = st.select_slider( + "Persuasion Level", + options=persuasion_options, + value=default_persuasion, + help="Select how persuasive you want your letter to be.", + key=f"{subtype}_persuasion" + ) + + with col2: + # Slider for Length, using session state default. + length_options = ["Brief", "Standard", "Detailed"] + default_length = st.session_state.business_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" + ) + + # Slider for Formality, using session state default. + formality_options = ["Conversational", "Professional", "Formal"] + default_formality = st.session_state.business_letter_form_data.get("formality", "Professional") + form_data["formality"] = st.select_slider( + "Formality", + options=formality_options, + value=default_formality, + help="Select the formality level of your letter.", + key=f"{subtype}_formality" + ) + + # Section for defining a Call to Action. + st.markdown("### Call to Action") + default_include_cta = st.session_state.business_letter_form_data.get("include_cta", True) + include_cta = st.checkbox("Include a call to action", value=default_include_cta, help="Check to include a clear call to action in your letter.", key=f"{subtype}_include_cta") + + cta_type = None + custom_cta = None + form_data["cta_type"] = None # Initialize to None + form_data["custom_cta"] = None # Initialize to None + if include_cta: + default_cta_type = st.session_state.business_letter_form_data.get("cta_type", "Request a Meeting") + cta_options = ["Request a Meeting", "Place an Order", "Contact for More Information", "Visit Website", "Call", "Email", "Custom"] + cta_type = st.selectbox( + "Call to Action Type", + cta_options, + index=cta_options.index(default_cta_type) if default_cta_type in cta_options else 0, + help="Select the type of call to action you want to include, or choose 'Custom'.", + key=f"{subtype}_cta_type" + ) + form_data["cta_type"] = cta_type # Add selected CTA type to form_data for prompt + + if cta_type == "Custom": + default_custom_cta = st.session_state.business_letter_form_data.get("custom_cta", "") + custom_cta = st.text_input( + "Custom Call to Action", + value=default_custom_cta, + help="Enter your custom call to action phrase.", + placeholder="e.g., Register for our upcoming webinar", + key=f"{subtype}_custom_cta" + ) + form_data["custom_cta"] = custom_cta # Add to form_data for prompt + + + # Section for adding additional persuasive elements like offers, testimonials, or urgency. + with st.expander("Additional Options"): + default_include_special_offer = st.session_state.business_letter_form_data.get("include_special_offer", False) + include_special_offer = st.checkbox("Include a special offer or incentive", value=default_include_special_offer, help="Add a specific offer to encourage action.", key=f"{subtype}_include_special_offer") + form_data["special_offer"] = None # Initialize to None + if include_special_offer: + default_special_offer = st.session_state.business_letter_form_data.get("special_offer", "") + form_data["special_offer"] = st.text_area( + "Special Offer/Incentive Details", + value=default_special_offer, + height=100, + help="Describe the special offer or incentive.", + placeholder="e.g., 15% discount for orders placed before June 30", + key=f"{subtype}_special_offer" + ) + + default_include_testimonial = st.session_state.business_letter_form_data.get("include_testimonial", False) + include_testimonial = st.checkbox("Include a testimonial or social proof", value=default_include_testimonial, help="Add a quote from a satisfied customer or relevant statistic.", key=f"{subtype}_include_testimonial") + form_data["testimonial"] = None # Initialize to None + if include_testimonial: + default_testimonial = st.session_state.business_letter_form_data.get("testimonial", "") + form_data["testimonial"] = st.text_area( + "Testimonial/Social Proof Details", + value=default_testimonial, + height=100, + help="Add a testimonial or social proof to strengthen your message.", + placeholder="e.g., 'ABC Company helped us increase our revenue by 30% in just three months.' - John Smith, CEO of XYZ Corp", + key=f"{subtype}_testimonial" + ) + + default_include_urgency = st.session_state.business_letter_form_data.get("include_urgency", False) + include_urgency = st.checkbox("Include urgency or scarcity elements", value=default_include_urgency, help="Add elements that encourage prompt action.", key=f"{subtype}_include_urgency") + form_data["urgency"] = None # Initialize to None + if include_urgency: + default_urgency = st.session_state.business_letter_form_data.get("urgency", "") + form_data["urgency"] = st.text_area( + "Urgency/Scarcity Details", + value=default_urgency, + height=100, + help="Add elements that create a sense of urgency or scarcity.", + placeholder="e.g., Limited time offer - only 10 spots available", + key=f"{subtype}_urgency" + ) + + # --- Tab 2: Business Information --- + with tab2: + # Section for sender and recipient business information. + col3, col4 = st.columns(2) + + with col3: + st.markdown("### Your Company Information") + # Input fields for sender's business details, populated from session state. + form_data["sender_company"] = st.text_input("Your Company Name", value=st.session_state.business_letter_form_data.get("sender_company", ""), help="Your company's full legal name.", key=f"{subtype}_sender_company") + form_data["sender_name"] = st.text_input("Your Name", value=st.session_state.business_letter_form_data.get("sender_name", ""), help="Your full name as the sender.", key=f"{subtype}_sender_name") + form_data["sender_title"] = st.text_input("Your Title/Position", value=st.session_state.business_letter_form_data.get("sender_title", ""), help="Your job title or position within the company.", key=f"{subtype}_sender_title") + form_data["sender_address"] = st.text_area("Your Company Address", value=st.session_state.business_letter_form_data.get("sender_address", ""), height=100, help="Full mailing address of your company.", key=f"{subtype}_sender_address") + form_data["sender_phone"] = st.text_input("Your Phone Number (Optional)", value=st.session_state.business_letter_form_data.get("sender_phone", ""), help="Your contact phone number.", key=f"{subtype}_sender_phone") + form_data["sender_email"] = st.text_input("Your Email Address (Optional)", value=st.session_state.business_letter_form_data.get("sender_email", ""), help="Your contact email address.", key=f"{subtype}_sender_email") + form_data["sender_website"] = st.text_input("Your Website (Optional)", value=st.session_state.business_letter_form_data.get("sender_website", ""), help="Your company website URL.", key=f"{subtype}_sender_website") + + with col4: + st.markdown("### Recipient Information") + # Input fields for recipient's business details, populated from session state. + form_data["recipient_company"] = st.text_input("Recipient Company Name", value=st.session_state.business_letter_form_data.get("recipient_company", ""), help="The name of the company you are writing to.", key=f"{subtype}_recipient_company") + form_data["recipient_name"] = st.text_input("Recipient Name (Optional)", value=st.session_state.business_letter_form_data.get("recipient_name", ""), help="The name of the specific person you are writing to (if known).", key=f"{subtype}_recipient_name") + form_data["recipient_title"] = st.text_input("Recipient Title/Position (Optional)", value=st.session_state.business_letter_form_data.get("recipient_title", ""), help="The recipient's job title or position (if known).", key=f"{subtype}_recipient_title") + form_data["recipient_address"] = st.text_area("Recipient Address", value=st.session_state.business_letter_form_data.get("recipient_address", ""), height=100, help="Full mailing address of the recipient's company.", key=f"{subtype}_recipient_address") + + # Optional recipient contact information in an expander. + with st.expander("Additional Recipient Information (Optional)"): + form_data["recipient_phone"] = st.text_input("Recipient Phone Number (Optional)", value=st.session_state.business_letter_form_data.get("recipient_phone", ""), help="Recipient's contact phone number.", key=f"{subtype}_recipient_phone") + form_data["recipient_email"] = st.text_input("Recipient Email Address (Optional)", value=st.session_state.business_letter_form_data.get("recipient_email", ""), help="Recipient's contact email address.", key=f"{subtype}_recipient_email") + + # Section for defining the business relationship. + st.markdown("### Business Relationship") + relationship_options = ["New Prospect", "Existing Customer", "Former Customer", "Partner/Vendor", "Other"] + default_relationship_type = st.session_state.business_letter_form_data.get("relationship_type", "New Prospect") + form_data["relationship_type"] = st.selectbox( + "Relationship with Recipient", + relationship_options, + index=relationship_options.index(default_relationship_type) if default_relationship_type in relationship_options else 0, + help="Select your current business relationship with the recipient.", + key=f"{subtype}_relationship_type" + ) + + form_data["relationship_duration"] = None # Initialize to None + if form_data["relationship_type"] == "Existing Customer": + default_relationship_duration = st.session_state.business_letter_form_data.get("relationship_duration", "") + form_data["relationship_duration"] = st.text_input( + "Relationship Duration", + value=default_relationship_duration, + help="How long have you been doing business with this customer? (e.g., 3 years, since 2020)", + placeholder="e.g., 3 years", + key=f"{subtype}_relationship_duration" + ) + + # Section for letter formatting options. + st.markdown("### Letter Format") + format_options = ["Full Block", "Modified Block", "Semi-Block"] + default_letter_format = st.session_state.business_letter_form_data.get("letter_format", "Full Block") + form_data["letter_format"] = st.selectbox( + "Format Style", + format_options, + index=format_options.index(default_letter_format) if default_letter_format in format_options else 0, + help="Select the standard business letter format style.", + key=f"{subtype}_letter_format" + ) + + default_include_letterhead = st.session_state.business_letter_form_data.get("include_letterhead", True) + form_data["include_letterhead"] = st.checkbox("Include letterhead", value=default_include_letterhead, help="Include your company's letterhead information at the top.", key=f"{subtype}_include_letterhead") + + default_include_subject_line = st.session_state.business_letter_form_data.get("include_subject_line", True) + include_subject_line = st.checkbox("Include subject line", value=default_include_subject_line, help="Include a clear subject line.", key=f"{subtype}_include_subject_line") + form_data["subject_line"] = None # Initialize to None + form_data["include_subject_line"] = include_subject_line # Store checkbox state + if include_subject_line: + default_subject_line = st.session_state.business_letter_form_data.get("subject_line", "") + form_data["subject_line"] = st.text_input( + "Subject Line Text", + value=default_subject_line, + help="Enter the text for the subject line.", + placeholder="e.g., Special Offer for Premium Customers", + key=f"{subtype}_subject_line" + ) + + default_include_reference_number = st.session_state.business_letter_form_data.get("include_reference_number", False) + include_reference_number = st.checkbox("Include reference number", value=default_include_reference_number, help="Include a reference number for tracking.", key=f"{subtype}_include_reference_number") + form_data["reference_number"] = None # Initialize to None + form_data["include_reference_number"] = include_reference_number # Store checkbox state + if include_reference_number: + default_reference_number = st.session_state.business_letter_form_data.get("reference_number", "") + form_data["reference_number"] = st.text_input( + "Reference Number Text", + value=default_reference_number, + help="Enter the reference number.", + placeholder="e.g., REF-2023-123", + key=f"{subtype}_reference_number" + ) + + # --- Tab 3: Preview & Export --- + with tab3: + # Instructions for the user before generation. + if not st.session_state.business_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.business_letter_form_data = form_data.copy() + + # Prepare metadata specifically for the formatter and analysis functions. + # This includes structured contact info, dates, subject, etc. + metadata = { + "sender_company": form_data.get("sender_company", ""), + "sender_name": form_data.get("sender_name", ""), + "sender_title": form_data.get("sender_title", ""), + "sender_address": form_data.get("sender_address", ""), + "sender_phone": form_data.get("sender_phone", ""), + "sender_email": form_data.get("sender_email", ""), + "sender_website": form_data.get("sender_website", ""), + "recipient_company": form_data.get("recipient_company", ""), + "recipient_name": form_data.get("recipient_name", ""), + "recipient_title": form_data.get("recipient_title", ""), + "recipient_address": form_data.get("recipient_address", ""), + "recipient_phone": form_data.get("recipient_phone", ""), + "recipient_email": form_data.get("recipient_email", ""), + "relationship_type": form_data.get("relationship_type", ""), + "date": datetime.datetime.now().strftime("%B %d, %Y"), # Use current date for the letter + "letter_format": form_data.get("letter_format", "Full Block"), + "include_letterhead": form_data.get("include_letterhead", True), + "subject": form_data.get('subject_line') if form_data.get('include_subject_line') else "", # Include subject in metadata for formatter + "reference_number": form_data.get('reference_number') if form_data.get('include_reference_number') else "", # Include reference in metadata + } + # Determine salutation based on recipient name/title preference + recipient_display_name = metadata.get("recipient_name") + recipient_display_title = metadata.get("recipient_title") + if recipient_display_name and recipient_display_title: + metadata["salutation"] = f"Dear {recipient_display_title} {recipient_display_name}:" + elif recipient_display_name: + metadata["salutation"] = f"Dear {recipient_display_name}:" + else: + metadata["salutation"] = "Dear Sir/Madam:" # Fallback salutation + + # Determine complimentary close based on formality + metadata["complimentary_close"] = "Sincerely," # Standard business close + + + st.session_state.business_letter_metadata = metadata.copy() + + + # --- Letter Generation Logic --- + # Check for minimal required fields before attempting generation. + if not form_data.get("sender_company") or not form_data.get("recipient_company"): + st.error("Please provide at least your company name and the recipient's company name.") + else: + # Display a spinner while the AI generates the letter. + with st.spinner("Generating your business letter..."): + # Combine all necessary data into a single dictionary for the generation function. + # This includes both form data and metadata. + # Note: tone, persuasion_level, length, formality are already in form_data + generation_data = { + "subtype": subtype, + **form_data, # Includes all collected form inputs + **metadata # Includes structured sender/recipient/date/format info + } + + # Call the letter generation function with the combined data. + letter_content = generate_business_letter(generation_data) + + # Store the generated letter content and update the generated flag. + st.session_state.business_letter_content = letter_content + st.session_state.business_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.business_letter_generated and st.session_state.business_letter_content is not None: + letter_content = st.session_state.business_letter_content + metadata = st.session_state.business_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="business" to the formatter. + html_preview = get_letter_preview_html(letter_content, metadata, letter_type="business") + st.markdown(html_preview, unsafe_allow_html=True) + + # Download button for the plain text version of the letter. + file_name_suffix = metadata.get('recipient_company', 'business').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, "business") # Pass "business" 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.business_letter_generated = False + st.session_state.business_letter_content = None # Clear generated content + # st.session_state.business_letter_form_data is already populated from the form submit + st.rerun() # Rerun to show the form with previous inputs + + +def generate_business_letter(data: Dict[str, Any]) -> str: + """ + Generates a business 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", "Professional") + persuasion_level = data.get("persuasion_level", "Moderate") + length = data.get("length", "Standard") + formality = data.get("formality", "Professional") + + # Get template guidance and structure to include in the prompt. + template = get_template_by_type("business", subtype) + template_guidance = template.get("guidance", "Follow standard business letter practices.") + template_structure = template.get("structure", ["Introduction", "Body", "Call to Action", "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()}, {formality.lower()} business {get_name_for_subtype(subtype)} letter with a {tone.lower()} tone and {persuasion_level.lower()} persuasion level.", + f"Purpose: {get_description_for_subtype(subtype)}", + f"Recipient: {data.get('recipient_name', '')}, {data.get('recipient_title', '')} at {data.get('recipient_company', '')}", + f"Sender: {data.get('sender_name', '')}, {data.get('sender_title', '')} at {data.get('sender_company', '')}", + f"Relationship: {data.get('relationship_type', 'Not specified')}{' for ' + data.get('relationship_duration', '') if data.get('relationship_duration') else ''}", + f"Date: {data.get('date', '')}", + f"Desired Format Style: {data.get('letter_format', 'Full Block')}", + ] + + # Add subject line if provided + if data.get('include_subject_line') and data.get('subject_line'): + prompt_parts.append(f"Subject: {data['subject_line']}") + + # Add reference number if provided + if data.get('include_reference_number') and data.get('reference_number'): + prompt_parts.append(f"Reference Number: {data['reference_number']}") + + + # 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 additional persuasive options if included. + if data.get('include_cta') and data.get('cta_type'): + cta_text = data.get('custom_cta') if data.get('cta_type') == "Custom" else data['cta_type'] + if cta_text: + prompt_parts.append(f"Include a clear Call to Action: {cta_text}") + + if data.get('include_special_offer') and data.get('special_offer'): + prompt_parts.append(f"Include this Special Offer/Incentive: {data['special_offer']}") + + if data.get('include_testimonial') and data.get('testimonial'): + prompt_parts.append(f"Include this Testimonial/Social Proof: {data['testimonial']}") + + if data.get('include_urgency') and data.get('urgency'): + prompt_parts.append(f"Include Urgency/Scarcity Elements: {data['urgency']}") + + # 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 professional, persuasive, and appropriate for business communication.") + + + # 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 business letter subtype ID to a relevant emoji icon.""" + icons = { + "sales": "💰", + "proposal": "📊", + "order": "🛒", + "quotation": "💲", + "acknowledgment": "✅", + "collection": "💵", + "adjustment": "🔧", + "credit": "💳", + "follow_up": "🔄" + } + return icons.get(subtype, "📝") # Default icon + +def get_name_for_subtype(subtype: str) -> str: + """Maps a business letter subtype ID to its display name.""" + names = { + "sales": "Sales Letter", + "proposal": "Business Proposal", + "order": "Order Letter", + "quotation": "Quotation Letter", + "acknowledgment": "Acknowledgment Letter", + "collection": "Collection Letter", + "adjustment": "Adjustment Letter", + "credit": "Credit Letter", + "follow_up": "Follow-up Letter" + } + return names.get(subtype, "Business Letter") # Default name + +def get_description_for_subtype(subtype: str) -> str: + """Maps a business letter subtype ID to a brief description.""" + descriptions = { + "sales": "Promote products or services to potential customers with a persuasive sales letter.", + "proposal": "Present a business idea or solution with a comprehensive business proposal.", + "order": "Place an order for products or services with a clear and detailed order letter.", + "quotation": "Provide pricing information for products or services with a professional quotation letter.", + "acknowledgment": "Confirm receipt of payment, order, or documents with an acknowledgment letter.", + "collection": "Request payment for overdue accounts with a firm but professional collection letter.", + "adjustment": "Respond to customer complaints or requests with a solution-oriented adjustment letter.", + "credit": "Extend credit to customers or respond to credit requests with a clear credit letter.", + "follow_up": "Follow up on previous communication or meetings with a purposeful follow-up letter." + } + return descriptions.get(subtype, "Create a business 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 business 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 == "sales": + return [ + { + "id": "product_service", + "label": "Product/Service", + "type": "text", + "help": "What specific product or service are you promoting?" + }, + { + "id": "benefits", + "label": "Key Benefits", + "type": "textarea", + "help": "List the main benefits your product or service offers to the recipient." + }, + { + "id": "usp", + "label": "Unique Selling Points", + "type": "textarea", + "help": "What makes your product or service stand out from competitors?" + }, + { + "id": "pricing", + "label": "Pricing Information", + "type": "textarea", + "help": "Include relevant pricing details or a call to action to learn about pricing. (optional)" + } + ] + elif subtype == "proposal": + return [ + { + "id": "proposal_type", + "label": "Proposal Type", + "type": "text", + "help": "What type of proposal is this? (e.g., service proposal, partnership proposal)" + }, + { + "id": "problem", + "label": "Problem Statement", + "type": "textarea", + "help": "What problem or need does your proposal address?" + }, + { + "id": "solution", + "label": "Proposed Solution", + "type": "textarea", + "help": "What solution are you proposing?" + }, + { + "id": "implementation", + "label": "Implementation Plan", + "type": "textarea", + "help": "How will your solution be implemented?" + }, + { + "id": "cost_timeline", + "label": "Cost and Timeline Summary", + "type": "textarea", + "help": "Provide a summary of the costs involved and the overall timeline." + } + ] + elif subtype == "order": + return [ + { + "id": "ordered_items", + "label": "Products/Services Ordered", + "type": "textarea", + "help": "List the products or services you are ordering, including quantities, model numbers, and any specific details." + }, + { + "id": "delivery", + "label": "Delivery Details", + "type": "textarea", + "help": "Specify delivery requirements: requested date, shipping address (if different from recipient address), shipping method, and any special instructions." + }, + { + "id": "payment_terms", + "label": "Payment Terms", + "type": "text", + "help": "State the agreed-upon payment terms for this order (e.g., Net 30, Payment in Advance)." + }, + { + "id": "po_number", + "label": "Purchase Order Number", + "type": "text", + "help": "Enter the Purchase Order number if applicable for tracking." + } + ] + elif subtype == "quotation": + return [ + { + "id": "quoted_items", + "label": "Products/Services Quoted", + "type": "textarea", + "help": "List the products or services you are providing a quote for, including brief descriptions and specifications." + }, + { + "id": "pricing_details", + "label": "Pricing Details", + "type": "textarea", + "help": "Provide a breakdown of pricing for each item or service, including unit price, quantity, and line total. Mention taxes or fees separately." + }, + { + "id": "validity", + "label": "Validity Period", + "type": "text", + "help": "How long is this quotation valid for?" + }, + { + "id": "terms", + "label": "Terms and Conditions", + "type": "textarea", + "help": "Include any relevant terms and conditions related to this quotation. (optional)" + } + ] + elif subtype == "acknowledgment": + return [ + { + "id": "acknowledging", + "label": "Acknowledging", + "type": "text", + "help": "What specific item, payment, order, or document are you acknowledging receipt of?" + }, + { + "id": "date_received", + "label": "Date Received", + "type": "date", # Using date type for date input + "help": "When was the item, payment, order, or document received?" + }, + { + "id": "reference_info", + "label": "Reference Information", + "type": "text", + "help": "Include any relevant reference numbers (e.g., Order ID, Invoice #, Case Number)." + }, + { + "id": "next_steps", + "label": "Next Steps", + "type": "textarea", + "help": "Outline the next steps or actions that will be taken regarding this item (e.g., 'Your order is being processed', 'Your payment has been applied'). (optional)" + } + ] + elif subtype == "collection": + return [ + { + "id": "invoice_number", + "label": "Invoice Number", + "type": "text", + "help": "What is the invoice number for the overdue payment?" + }, + { + "id": "amount_due", + "label": "Amount Due", + "type": "text", # Using text to allow currency symbols like $ or € + "help": "What is the total outstanding amount due?" + }, + { + "id": "due_date", + "label": "Original Due Date", + "type": "date", # Using date type for date input + "help": "When was the payment originally due?" + }, + { + "id": "days_overdue", + "label": "Days Overdue", + "type": "number", + "min": 0, # Minimum value is 0 days overdue + "help": "How many days is the payment currently overdue?" + }, + { + "id": "payment_history", + "label": "Payment History Summary", + "type": "textarea", + "help": "Briefly summarize previous attempts to collect payment or communication about the invoice. (optional)" + }, + { + "id": "consequences", + "label": "Consequences of Non-Payment", + "type": "textarea", + "help": "Clearly state the consequences of continued non-payment (e.g., late fees, referral to collections, legal action). Tailor the severity to the stage of collection. (optional)" + } + ] + elif subtype == "adjustment": + return [ + { + "id": "customer_name_adj", # Added suffix to avoid potential ID clashes with recipient_name + "label": "Customer Name", + "type": "text", + "help": "Name of the customer who submitted the complaint or request." + }, + { + "id": "complaint_request", + "label": "Customer Complaint/Request Summary", + "type": "textarea", + "help": "Provide a brief summary of the customer's complaint or request." + }, + { + "id": "date_of_issue", + "label": "Date of Issue/Complaint", + "type": "date", # Using date type for date input + "help": "When did the issue occur or when was the complaint/request received?" + }, + { + "id": "findings", + "label": "Investigation Findings", + "type": "textarea", + "help": "Describe what your investigation found regarding the customer's issue." + }, + { + "id": "adjustment", + "label": "Adjustment Offered", + "type": "textarea", + "help": "Clearly describe the specific adjustment you are offering to resolve the issue (e.g., full refund, partial refund, replacement product, service credit)." + }, + { + "id": "preventive_measures", + "label": "Preventive Measures", + "type": "textarea", + "help": "What measures will be taken to prevent similar issues from happening in the future? (optional)" + } + ] + elif subtype == "credit": + return [ + { + "id": "credit_request_type", + "label": "Credit Request Type", + "type": "select", + "options": ["New Credit Application", "Credit Limit Increase Request", "Credit Terms Adjustment Request", "Response to Credit Inquiry"], + "help": "What type of credit-related request or response is this letter for?" + }, + { + "id": "applicant_name", + "label": "Applicant Name", + "type": "text", + "help": "Name of the individual or company applying for or receiving credit." + }, + { + "id": "credit_decision", + "label": "Credit Decision", + "type": "select", + "options": ["Approved", "Denied", "Conditionally Approved", "Under Review", "Information Required"], + "help": "What is the outcome or status of the credit application/request?" + }, + { + "id": "credit_terms", + "label": "Credit Terms", + "type": "textarea", + "help": "Describe the credit terms being extended or requested (e.g., Net 30, 2% 10 Net 30, interest rate, payment schedule). (Required if Approved/Conditionally Approved)" + }, + { + "id": "credit_limit", + "label": "Credit Limit", + "type": "text", # Using text to allow for "N/A" or specific amounts + "help": "What is the approved credit limit? (Required if Approved/Conditionally Approved)" + }, + { + "id": "requirements", + "label": "Requirements/Conditions", + "type": "textarea", + "help": "List any specific requirements or conditions that must be met for the credit. (Optional)" + }, + { + "id": "reason_for_decision", + "label": "Reason for Decision (if Denied/Conditional/Info Required)", + "type": "textarea", + "help": "Clearly explain the reason for the credit decision or why more information is needed. (Required if not Approved)" + } + ] + elif subtype == "follow_up": + return [ + { + "id": "previous_communication_type", + "label": "Previous Communication Type", + "type": "select", + "options": ["Meeting", "Phone Call", "Email", "Proposal Submission", "Interview", "Networking Event", "Other"], + "help": "What type of previous interaction are you following up on?" + }, + { + "id": "previous_date", + "label": "Date of Previous Contact", + "type": "date", # Using date type for date input + "help": "When was the previous communication or meeting?" + }, + { + "id": "previous_topic", + "label": "Topic of Previous Contact", + "type": "text", + "help": "What was the main subject or topic discussed during the previous interaction?" + }, + { + "id": "follow_up_purpose", + "label": "Purpose of Follow-up", + "type": "textarea", + "help": "Why are you following up now? What is the specific goal of this letter?" + }, + { + "id": "action_items", + "label": "Proposed Next Steps/Action Items", + "type": "textarea", + "help": "What actions or next steps are you proposing or inquiring about as a result of this follow-up? (optional)" + } + ] + + # 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 business letter." + } + ] + +def get_tones_for_subtype(subtype: str) -> List[str]: + """Maps a business letter subtype ID to a list of suggested tones.""" + tones = { + "sales": ["Enthusiastic", "Confident", "Friendly", "Professional", "Persuasive"], + "proposal": ["Professional", "Confident", "Collaborative", "Solution-oriented", "Authoritative"], + "order": ["Clear", "Direct", "Professional", "Courteous", "Precise"], + "quotation": ["Professional", "Helpful", "Informative", "Precise", "Courteous"], + "acknowledgment": ["Appreciative", "Professional", "Courteous", "Helpful", "Prompt"], + "collection": ["Firm", "Professional", "Respectful", "Direct", "Urgent"], + "adjustment": ["Empathetic", "Professional", "Solution-oriented", "Apologetic", "Helpful"], + "credit": ["Professional", "Helpful", "Informative", "Trustworthy", "Clear"], + "follow_up": ["Friendly", "Professional", "Persistent", "Helpful", "Courteous"] + } + # Return the list of tones for the subtype, or a default list if not found. + return tones.get(subtype, ["Professional", "Courteous", "Clear", "Helpful", "Friendly"]) + +# 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() diff --git a/lib/ai_writers/ai_letter_writer/cover_letters.py b/lib/ai_writers/ai_letter_writer/cover_letters.py new file mode 100644 index 00000000..1792ed25 --- /dev/null +++ b/lib/ai_writers/ai_letter_writer/cover_letters.py @@ -0,0 +1,1135 @@ +""" +Cover Letters Module + +This module provides a Streamlit interface for generating various types of cover letters +using AI assistance. It collects user inputs specific to the chosen cover 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, including ATS compatibility. +""" + +import streamlit as st +import datetime +import re # Import regex for ATS score calculation +from typing import Dict, Any, List +from collections import Counter # Import Counter for ATS keyword frequency + +# 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.""" + # Basic HTML structure with inline styles for preview + formatted_paragraphs = "".join(f"

{p.strip()}

" for p in content.split("\n\n") if p.strip()) + return f""" +
+
{metadata.get('date', 'Date')}
+
Subject: Application for {metadata.get('job_title', 'Position')}
+
{metadata.get('salutation', 'Dear Hiring Manager,')}
+
{formatted_paragraphs if formatted_paragraphs else "

Letter content goes here...

"}
+
{metadata.get('complimentary_close', 'Sincerely,')}
+
{metadata.get('sender_name', 'Your Name')}
+
+ """ + +def analyze_letter_tone(content: str) -> Dict[str, float]: + """Placeholder: Returns dummy tone analysis.""" + # Returns scores between 0.0 and 1.0 + return {"professional": 0.85, "enthusiastic": 0.7, "confident": 0.8} + +def check_formality(content: str) -> float: + """Placeholder: Returns a dummy formality score (0.0 to 1.0).""" + return 0.90 # Example: 90% 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": 55.0, # Dummy score + "reading_level": "Fairly Difficult", # 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) < 150: + return ["Suggestion: Your cover letter seems quite brief. Consider elaborating on your key qualifications."] + elif "generic skill" in content.lower(): + return ["Suggestion: Replace generic skills with specific examples or achievements."] + else: + return ["Suggestion: Ensure your letter clearly matches the job description keywords."] + +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": ["Introduction", "Body Paragraphs", "Closing"], "guidance": "Generic guidance for a cover 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"Subject: Application for [Generated Job Title] Position\n\nDear [Generated Hiring Manager Name],\n\nThis is a sample cover 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, and focus area.]\n\nSincerely,\n[Generated Your Name]" + +# --- End Placeholder Functions --- + + +def write_letter(): + """ + Main function for the Cover Letters interface. Sets up the Streamlit page + and handles navigation between subtype selection and the letter form. + """ + + # Page title and description + st.title("📄 Cover Letter Writer") + st.markdown(""" + Create professional cover letters tailored to specific job applications. Select a cover letter type below to get started. + """) + + # Initialize Streamlit session state variables specific to the cover letter module. + # These variables persist across reruns and store the user's progress and data. + if "cover_letter_subtype" not in st.session_state: + st.session_state.cover_letter_subtype = None # Stores the ID of the selected cover letter type + if "cover_letter_generated" not in st.session_state: + st.session_state.cover_letter_generated = False # Flag to indicate if a letter has been generated + if "cover_letter_content" not in st.session_state: + st.session_state.cover_letter_content = None # Stores the generated letter content + if "cover_letter_metadata" not in st.session_state: + st.session_state.cover_letter_metadata = {} # Stores metadata like sender/recipient info + if "cover_letter_form_data" not in st.session_state: + st.session_state.cover_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.cover_letter_subtype is not None: + if st.button("← Back to Cover 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.cover_letter_subtype = None + st.session_state.cover_letter_generated = False + st.session_state.cover_letter_content = None + st.session_state.cover_letter_metadata = {} + st.session_state.cover_letter_form_data = {} + st.rerun() # Rerun the app to update the UI based on the changed state + + # Main navigation logic within the cover letter module. + # If no subtype is selected, show the selection grid. Otherwise, show the form for the selected subtype. + if st.session_state.cover_letter_subtype is None: + # Display cover letter type selection if no subtype is selected + display_cover_letter_types() + else: + # Display the interface form for the selected cover letter subtype + display_cover_letter_form(st.session_state.cover_letter_subtype) + + +def display_cover_letter_types(): + """ + Displays the cover letter type selection interface using a grid of styled buttons. + Each button represents a specific type of cover letter the user can choose to write. + """ + + st.markdown("## Select Cover Letter Type") + + # Define cover letter types with their details (ID, Name, Icon, Description, Color) + # This list is used to generate the selection buttons. + cover_letter_types = [ + { + "id": "standard", + "name": "Standard Cover Letter", + "icon": "📝", + "description": "General purpose cover letter for most job applications", + "color": "#1976D2" # Blue + }, + { + "id": "career_change", + "name": "Career Change", + "icon": "🔄", + "description": "Highlight transferable skills when changing careers", + "color": "#388E3C" # Green + }, + { + "id": "entry_level", + "name": "Entry Level", + "icon": "🌱", + "description": "For recent graduates or those with limited experience", + "color": "#FFA000" # Orange + }, + { + "id": "executive", + "name": "Executive", + "icon": "👔", + "description": "For senior management and executive positions", + "color": "#5D4037" # Brown + }, + { + "id": "creative", + "name": "Creative", + "icon": "🎨", + "description": "For creative industries like design, writing, or marketing", + "color": "#7B1FA2" # Purple + }, + { + "id": "technical", + "name": "Technical", + "icon": "💻", + "description": "For IT, engineering, and other technical roles", + "color": "#0097A7" # Teal + }, + { + "id": "academic", + "name": "Academic", + "icon": "🎓", + "description": "For positions in education and research", + "color": "#D32F2F" # Red + }, + { + "id": "remote", + "name": "Remote Position", + "icon": "🏠", + "description": "Emphasize skills for working remotely", + "color": "#455A64" # Blue Grey + }, + { + "id": "referral", + "name": "Referral", + "icon": "👥", + "description": "Mention a referral or connection at the company", + "color": "#FF5722" # Deep Orange + } + ] + + # Inject custom CSS to style the Streamlit buttons to look like cards. + # This provides a visually appealing selection grid. + st.markdown(""" + + """, 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(cover_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

{letter_type_config['description']}

", + key=f"btn_cover_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.cover_letter_subtype = letter_type_config['id'] + # Clear previous data related to letter generation when selecting a new type + st.session_state.cover_letter_generated = False + st.session_state.cover_letter_content = None + st.session_state.cover_letter_metadata = {} + st.session_state.cover_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 cover_letter_types: + button_styles += f""" + div.stButton > button[data-testid="stButton"][kind="primary"][sf-key*="btn_cover_select_{letter_type_config['id']}"] {{ + background-color: {letter_type_config['color']}; + }} + div.stButton > button[data-testid="stButton"][kind="primary"][sf-key*="btn_cover_select_{letter_type_config['id']}"]:hover {{ + background-color: {letter_type_config['color']}D9; /* Slightly darker on hover */ + }} + """ + st.markdown(f"", unsafe_allow_html=True) + + +def display_cover_letter_form(subtype: str): + """ + Displays the form for the selected cover letter subtype. This includes + input fields specific to the subtype, personal profile information, + skills and experience fields, customization options, and tabs for + previewing and analyzing the generated letter. + + Args: + subtype: The ID string of the selected cover 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("cover", 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"cover_letter_form_{subtype}"): + # Create tabs to organize the form sections. + tab1, tab2, tab3, tab4 = st.tabs(["Job Details", "Your Profile", "Skills & Experience", "Preview & Export"]) + + # Dictionary to store form data collected from all tabs + form_data = {} + + # --- Tab 1: Job Details --- + with tab1: + st.markdown("### Job Information") + + # Input fields for job details, populated from session state. + form_data["job_title"] = st.text_input("Job Title", value=st.session_state.cover_letter_form_data.get("job_title", ""), help="The exact title of the position you're applying for.", key=f"{subtype}_job_title") + form_data["company_name"] = st.text_input("Company Name", value=st.session_state.cover_letter_form_data.get("company_name", ""), help="The full name of the company you're applying to.", key=f"{subtype}_company_name") + form_data["job_posting_url"] = st.text_input("Job Posting URL (Optional)", value=st.session_state.cover_letter_form_data.get("job_posting_url", ""), help="Provide the URL of the job posting if available. This helps tailor the letter.", key=f"{subtype}_job_posting_url") + + # Job description text area. Crucial for ATS analysis and tailoring. + form_data["job_description"] = st.text_area( + "Job Description", + value=st.session_state.cover_letter_form_data.get("job_description", ""), + height=200, + help="Copy and paste the full job description or list the key requirements and responsibilities.", + key=f"{subtype}_job_description" + ) + + # Optional fields for department and hiring manager. + col1, col2 = st.columns(2) + with col1: + form_data["department"] = st.text_input("Department (Optional)", value=st.session_state.cover_letter_form_data.get("department", ""), help="The specific department you are applying to, if known.", key=f"{subtype}_department") + with col2: + form_data["hiring_manager"] = st.text_input("Hiring Manager's Name (Optional)", value=st.session_state.cover_letter_form_data.get("hiring_manager", ""), help="The name of the hiring manager or recruiter if known. Using a name is highly recommended.", key=f"{subtype}_hiring_manager") + + # Application details section. + st.markdown("### Application Details") + application_methods = ["Online Application", "Email", "Company Website", "Job Board", "Referral", "Other"] + default_app_method = st.session_state.cover_letter_form_data.get("application_method", "Online Application") + form_data["application_method"] = st.selectbox( + "How are you applying?", + application_methods, + index=application_methods.index(default_app_method) if default_app_method in application_methods else 0, + help="Select the primary method you will use to submit this cover letter.", + key=f"{subtype}_application_method" + ) + + # Conditional fields for referral method. + if form_data["application_method"] == "Referral": + form_data["referral_name"] = st.text_input( + "Referral's Name", + value=st.session_state.cover_letter_form_data.get("referral_name", ""), + help="The full name of the person who referred you.", + key=f"{subtype}_referral_name" + ) + form_data["referral_relationship"] = st.text_input( + "Relationship to Referral", + value=st.session_state.cover_letter_form_data.get("referral_relationship", ""), + help="Briefly describe your relationship with the referrer (e.g., former colleague, friend, mentor).", + key=f"{subtype}_referral_relationship" + ) + else: + # Ensure referral fields are not in form_data if not selected + form_data["referral_name"] = None + form_data["referral_relationship"] = None + + + # Company research and interest sections. + st.markdown("### Company Research") + form_data["company_research"] = st.text_area( + "Company Research (Optional)", + value=st.session_state.cover_letter_form_data.get("company_research", ""), + height=150, + help="Share specific details you know about the company (e.g., mission, values, recent projects, news). This shows you've done your research.", + placeholder="e.g., I was impressed by your company's recent sustainability initiative and your commitment to innovation in the field of...", + key=f"{subtype}_company_research" + ) + + form_data["why_interested"] = st.text_area( + "Why You're Interested (Optional)", + value=st.session_state.cover_letter_form_data.get("why_interested", ""), + height=150, + help="Explain your genuine interest in this specific role and company. Connect it to your career goals or values.", + placeholder="e.g., I'm particularly drawn to this role because it aligns with my passion for...", + key=f"{subtype}_why_interested" + ) + + # --- Tab 2: Your Profile --- + with tab2: + st.markdown("### Personal Information") + + # Input fields for personal contact information. + col3, col4 = st.columns(2) + with col3: + form_data["full_name"] = st.text_input("Your Full Name", value=st.session_state.cover_letter_form_data.get("full_name", ""), help="Your full name as you want it to appear on the letter.", key=f"{subtype}_full_name") + form_data["email"] = st.text_input("Email Address", value=st.session_state.cover_letter_form_data.get("email", ""), help="Your professional email address.", key=f"{subtype}_email") + form_data["phone"] = st.text_input("Phone Number", value=st.session_state.cover_letter_form_data.get("phone", ""), help="Your primary contact phone number.", key=f"{subtype}_phone") + + with col4: + form_data["location"] = st.text_input("Location", value=st.session_state.cover_letter_form_data.get("location", ""), help="Your current city and state/country.", key=f"{subtype}_location") + form_data["linkedin"] = st.text_input("LinkedIn Profile URL (Optional)", value=st.session_state.cover_letter_form_data.get("linkedin", ""), help="Your LinkedIn profile URL.", key=f"{subtype}_linkedin") + form_data["portfolio"] = st.text_input("Portfolio/Website URL (Optional)", value=st.session_state.cover_letter_form_data.get("portfolio", ""), help="Your online portfolio or personal website URL.", key=f"{subtype}_portfolio") + + # Professional summary section. + st.markdown("### Professional Summary") + form_data["current_title"] = st.text_input("Current or Most Recent Job Title", value=st.session_state.cover_letter_form_data.get("current_title", ""), help="Your current or most recent job title.", key=f"{subtype}_current_title") + form_data["years_experience"] = st.number_input("Years of Relevant Experience", min_value=0, max_value=50, value=st.session_state.cover_letter_form_data.get("years_experience", 0), help="Total years of experience relevant to the target position.", key=f"{subtype}_years_experience") + + form_data["professional_summary"] = st.text_area( + "Professional Summary", + value=st.session_state.cover_letter_form_data.get("professional_summary", ""), + height=150, + help="A brief (2-3 sentence) summary highlighting your key qualifications and career goals. This is often at the top of a resume.", + placeholder="e.g., Results-driven marketing professional with 5+ years of experience in digital marketing strategies...", + key=f"{subtype}_professional_summary" + ) + + # Education section. + st.markdown("### Education") + degrees = ["High School", "Associate's", "Bachelor's", "Master's", "PhD", "Other", "None"] + default_degree = st.session_state.cover_letter_form_data.get("highest_degree", "Bachelor's") + form_data["highest_degree"] = st.selectbox( + "Highest Degree", + degrees, + index=degrees.index(default_degree) if default_degree in degrees else 0, + help="Your highest level of education achieved or in progress.", + key=f"{subtype}_highest_degree" + ) + + # Conditional education fields based on degree selection. + if form_data["highest_degree"] != "None": + form_data["field_of_study"] = st.text_input("Field of Study", value=st.session_state.cover_letter_form_data.get("field_of_study", ""), help="Your major or primary field of study.", key=f"{subtype}_field_of_study") + form_data["institution"] = st.text_input("Institution", value=st.session_state.cover_letter_form_data.get("institution", ""), help="Name of the school, college, or university.", key=f"{subtype}_institution") + # Set a reasonable default for graduation year, e.g., current year or a recent past year. + current_year = datetime.date.today().year + default_grad_year = st.session_state.cover_letter_form_data.get("graduation_year", current_year) + form_data["graduation_year"] = st.number_input("Graduation Year", min_value=1950, max_value=current_year + 5, value=default_grad_year, help="Year you graduated or expect to graduate.", key=f"{subtype}_graduation_year") + else: + form_data["field_of_study"] = None + form_data["institution"] = None + form_data["graduation_year"] = None + + + # --- Tab 3: Skills & Experience --- + with tab3: + st.markdown("### Key Skills") + + # Input fields for skills. + form_data["technical_skills"] = st.text_area( + "Technical Skills", + value=st.session_state.cover_letter_form_data.get("technical_skills", ""), + height=100, + help="List your technical skills relevant to the position, separated by commas or bullet points.", + placeholder="e.g., Python, SQL, Adobe Creative Suite, Financial Modeling, Project Management", + key=f"{subtype}_technical_skills" + ) + + form_data["soft_skills"] = st.text_area( + "Soft Skills", + value=st.session_state.cover_letter_form_data.get("soft_skills", ""), + height=100, + help="List your soft skills relevant to the position, separated by commas or bullet points.", + placeholder="e.g., Leadership, Communication, Problem-solving, Teamwork, Adaptability", + key=f"{subtype}_soft_skills" + ) + + form_data["certifications"] = st.text_area( + "Certifications (Optional)", + value=st.session_state.cover_letter_form_data.get("certifications", ""), + height=100, + help="List any relevant certifications you hold, separated by commas or bullet points.", + placeholder="e.g., PMP, CPA, AWS Certified Solutions Architect", + key=f"{subtype}_certifications" + ) + + # Work experience and achievements section. + st.markdown("### Relevant Experience") + + # Most relevant achievement field. Encourage metrics. + form_data["most_relevant_achievement"] = st.text_area( + "Most Relevant Achievement", + value=st.session_state.cover_letter_form_data.get("most_relevant_achievement", ""), + height=150, + help="Describe your single most impactful achievement that is highly relevant to this position. Use the STAR method (Situation, Task, Action, Result) and include metrics if possible.", + placeholder="e.g., Led a team of 5 to increase website conversion rates by 35% through A/B testing and UX improvements", + key=f"{subtype}_most_relevant_achievement" + ) + + # Additional achievements field. + form_data["additional_achievements"] = st.text_area( + "Additional Achievements (Optional)", + value=st.session_state.cover_letter_form_data.get("additional_achievements", ""), + height=150, + help="List 2-3 other significant achievements relevant to the position, using bullet points.", + placeholder="e.g., Reduced customer churn by 20% through implementation of new retention strategies\nManaged a $500K budget for marketing campaigns that generated $2.5M in revenue", + key=f"{subtype}_additional_achievements" + ) + + # Cover letter customization options. + st.markdown("### Cover Letter Customization") + + # Tone and style selection. + col5, col6 = st.columns(2) + with col5: + tones = get_tones_for_subtype(subtype) + default_tone = st.session_state.cover_letter_form_data.get("tone", tones[0] if tones else "Professional") + form_data["tone"] = st.selectbox( + "Tone", + tones, + index=tones.index(default_tone) if default_tone in tones else 0, + help="Select the desired tone for your cover letter.", + key=f"{subtype}_tone" + ) + + lengths = ["Brief", "Standard", "Detailed"] + default_length = st.session_state.cover_letter_form_data.get("length", "Standard") + form_data["length"] = st.select_slider( + "Length", + options=lengths, + value=default_length, + help="Select the desired length of your cover letter.", + key=f"{subtype}_length" + ) + + with col6: + focus_areas = ["Balanced", "Skills-focused", "Experience-focused", "Culture fit-focused", "Achievement-focused"] + default_focus = st.session_state.cover_letter_form_data.get("focus_area", "Balanced") + form_data["focus_area"] = st.selectbox( + "Focus Area", + focus_areas, + index=focus_areas.index(default_focus) if default_focus in focus_areas else 0, + help="Choose the main area you want the cover letter to emphasize.", + key=f"{subtype}_focus_area" + ) + + # Optional sections like salary expectations, availability, relocation, custom closing. + default_include_salary = st.session_state.cover_letter_form_data.get("include_salary", False) + include_salary = st.checkbox("Include salary expectations", value=default_include_salary, help="Check to include your salary expectations in the letter.", key=f"{subtype}_include_salary") + form_data["salary_expectations"] = None # Initialize to None + if include_salary: + default_salary = st.session_state.cover_letter_form_data.get("salary_expectations", "") + form_data["salary_expectations"] = st.text_input( + "Salary Expectations", + value=default_salary, + help="Your salary expectations (e.g., $70,000-$80,000 annually, Negotiable).", + placeholder="e.g., $70,000-$80,000 annually", + key=f"{subtype}_salary_expectations" + ) + + with st.expander("Additional Customization"): + default_include_availability = st.session_state.cover_letter_form_data.get("include_availability", False) + include_availability = st.checkbox("Include availability information", value=default_include_availability, help="Mention your availability to start or for interviews.", key=f"{subtype}_include_availability") + form_data["availability"] = None # Initialize to None + if include_availability: + default_availability = st.session_state.cover_letter_form_data.get("availability", "") + form_data["availability"] = st.text_input( + "Availability Details", + value=default_availability, + help="When you can start or your availability for interviews.", + placeholder="e.g., Available to start immediately or Available for interviews on weekdays after 3 PM", + key=f"{subtype}_availability" + ) + + default_include_relocation = st.session_state.cover_letter_form_data.get("include_relocation", False) + include_relocation = st.checkbox("Include relocation information", value=default_include_relocation, help="Specify your willingness to relocate or remote work preference.", key=f"{subtype}_include_relocation") + form_data["relocation_info"] = None # Initialize to None + if include_relocation: + default_relocation = st.session_state.cover_letter_form_data.get("relocation_info", "") + form_data["relocation_info"] = st.text_input( + "Relocation Information Details", + value=default_relocation, + help="Your willingness to relocate or remote work preferences.", + placeholder="e.g., Willing to relocate to Seattle area or Currently based in Chicago but open to remote work", + key=f"{subtype}_relocation_info" + ) + + default_include_closing = st.session_state.cover_letter_form_data.get("include_closing", False) + include_closing = st.checkbox("Include custom closing statement", value=default_include_closing, help="Provide a specific sentence or two for the letter's closing.", key=f"{subtype}_include_closing") + form_data["closing_statement"] = None # Initialize to None + if include_closing: + default_closing = st.session_state.cover_letter_form_data.get("closing_statement", "") + form_data["closing_statement"] = st.text_area( + "Custom Closing Statement Text", + value=default_closing, + height=100, + help="Enter your custom closing statement.", + placeholder="e.g., I would welcome the opportunity to discuss how my background and skills would be a good match for the [Job Title] position. Thank you for your consideration.", + key=f"{subtype}_closing_statement" + ) + + + # --- Tab 4: Preview & Export --- + with tab4: + # Instructions for the user before generation. + if not st.session_state.cover_letter_generated: + st.info("Complete the letter details and click 'Generate Cover 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 Cover 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.cover_letter_form_data = form_data.copy() + + # Prepare metadata specifically for the formatter and analysis functions. + # This includes structured contact info, dates, subject, etc. + metadata = { + "sender_name": form_data.get("full_name", ""), + "sender_email": form_data.get("email", ""), + "sender_phone": form_data.get("phone", ""), + "sender_location": form_data.get("location", ""), + "sender_linkedin": form_data.get("linkedin", ""), + "sender_portfolio": form_data.get("portfolio", ""), + "recipient_name": form_data.get("hiring_manager", "Hiring Manager"), # Default to "Hiring Manager" if name is not provided + "recipient_title": "", # Cover letters typically don't include recipient title here + "recipient_company": form_data.get("company_name", ""), + "recipient_department": form_data.get("department", ""), + "recipient_address": "", # Address is often omitted in modern cover letters unless specified + "date": datetime.datetime.now().strftime("%B %d, %Y"), # Use current date for the letter + "job_title": form_data.get("job_title", "") # Include job title in metadata for subject line + } + # Add salutation and complimentary close to metadata for the formatter + # These could be made configurable in the future + metadata["salutation"] = f"Dear {metadata['recipient_name']}," if metadata["recipient_name"] != "Hiring Manager" else "Dear Hiring Manager," + metadata["complimentary_close"] = "Sincerely," + + st.session_state.cover_letter_metadata = metadata.copy() + + + # --- Letter Generation Logic --- + # Check for minimal required fields before attempting generation. + if not form_data.get("job_title") or not form_data.get("company_name") or not form_data.get("full_name"): + st.error("Please provide at least the job title, company name, and your full name.") + else: + # Display a spinner while the AI generates the letter. + with st.spinner("Generating your cover letter..."): + # Call the letter generation function with the collected form data. + cover_letter_content = generate_cover_letter(subtype, form_data) + + # Store the generated letter content and update the generated flag. + st.session_state.cover_letter_content = cover_letter_content + st.session_state.cover_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.cover_letter_generated and st.session_state.cover_letter_content is not None: + cover_letter_content = st.session_state.cover_letter_content + metadata = st.session_state.cover_letter_metadata + job_description = st.session_state.cover_letter_form_data.get("job_description", "") # Get job description for ATS analysis + + # 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("### Cover Letter Preview") + # Generate and display the HTML preview of the letter using the formatter utility. + # Pass letter_type="cover" to the formatter. + html_preview = get_letter_preview_html(cover_letter_content, metadata, letter_type="cover") + st.markdown(html_preview, unsafe_allow_html=True) + + # Download button for the plain text version of the letter. + # Use job title in the filename for better organization. + file_name_job_title = metadata.get('job_title', 'cover_letter').replace(' ', '_').lower() + st.download_button( + label="Download as Text", + data=cover_letter_content, + file_name=f"{file_name_job_title}_{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("Cover Letter Content", cover_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("### Cover Letter Analysis") + # Perform and display analysis of the generated letter using utility functions. + + # Analyze tone, formality, and readability. + tone_analysis = analyze_letter_tone(cover_letter_content) + formality_score = check_formality(cover_letter_content) # Returns score between 0.0 and 1.0 + readability_metrics = get_readability_metrics(cover_letter_content) + # Get improvement suggestions, passing the letter type for context. + improvement_suggestions = suggest_improvements(cover_letter_content, "cover") # Pass "cover" as letter_type + + # Display analysis results in two columns. + col7, col8 = st.columns(2) + + with col7: + 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 col8: + 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.") + + # ATS compatibility analysis section. + st.markdown("#### ATS Compatibility") + + # Calculate ATS score based on keyword matching with the job description. + ats_score = calculate_ats_score(cover_letter_content, job_description) + + st.progress(ats_score / 100.0) # Progress bar expects 0.0 to 1.0 + st.write(f"ATS Compatibility Score: {ats_score}/100") + + # Provide feedback based on the ATS score. + if ats_score < 60: + st.warning("Your cover letter may not be well-optimized for Applicant Tracking Systems (ATS). Consider incorporating more keywords and phrases directly from the job description.") + elif ats_score < 80: + st.info("Your cover letter has a good ATS compatibility score. Review the job description and consider adding a few more relevant keywords if possible.") + else: + st.success("Your cover letter appears to be well-optimized for Applicant Tracking Systems (ATS).") + + 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 Cover Letter", key=f"{subtype}_regenerate_button"): + # Reset the generated state and content to allow the form to be displayed again. + st.session_state.cover_letter_generated = False + st.session_state.cover_letter_content = None # Clear generated content + # st.session_state.cover_letter_form_data is already populated from the form submit + st.rerun() # Rerun to show the form with previous inputs + + +def generate_cover_letter(subtype: str, data: Dict[str, Any]) -> str: + """ + Generates a cover letter using the LLM by constructing a detailed prompt + based on the collected user inputs and metadata. + + Args: + subtype: The ID string of the cover letter subtype. + 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. + tone = data.get("tone", "Professional") + length = data.get("length", "Standard") + focus_area = data.get("focus_area", "Balanced") + + # Get template guidance and structure to include in the prompt. + template = get_template_by_type("cover", subtype) + template_guidance = template.get("guidance", "Follow standard cover letter practices.") + template_structure = template.get("structure", ["Introduction", "Body Paragraphs", "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()} cover letter for a {get_name_for_subtype(subtype)} position with a {tone.lower()} tone.", + f"The letter should primarily focus on {focus_area.lower()} aspects.", + f"Applicant Name: {data.get('full_name', '')}", + f"Target Job Title: {data.get('job_title', '')}", + f"Target Company: {data.get('company_name', '')}", + f"Hiring Manager (if known): {data.get('hiring_manager', 'Not specified')}", + f"Application Method: {data.get('application_method', 'Not specified')}", + ] + + # Add job description if provided, as it's crucial for tailoring. + if data.get('job_description'): + prompt_parts.append(f"\nJob Description/Key Requirements:\n{data['job_description']}") + + # Add applicant's professional profile details. + prompt_parts.append("\nApplicant Profile:") + prompt_parts.append(f"- Current/Most Recent Title: {data.get('current_title', 'Not specified')}") + prompt_parts.append(f"- Years of Relevant Experience: {data.get('years_experience', 'Not specified')}") + if data.get('professional_summary'): + prompt_parts.append(f"- Professional Summary: {data['professional_summary']}") + + # Add education details if applicable. + if data.get('highest_degree') and data['highest_degree'] != "None": + education_details = f"{data['highest_degree']}" + if data.get('field_of_study'): + education_details += f" in {data['field_of_study']}" + if data.get('institution'): + education_details += f" from {data['institution']}" + if data.get('graduation_year'): + education_details += f" ({data['graduation_year']})" + prompt_parts.append(f"- Education: {education_details}") + else: + prompt_parts.append("- Education: Not specified") + + + # Add skills and experience details. + prompt_parts.append("\nSkills and Experience:") + if data.get('technical_skills'): + prompt_parts.append(f"- Technical Skills: {data['technical_skills']}") + if data.get('soft_skills'): + prompt_parts.append(f"- Soft Skills: {data['soft_skills']}") + if data.get('certifications'): + prompt_parts.append(f"- Certifications: {data['certifications']}") + if data.get('most_relevant_achievement'): + prompt_parts.append(f"- Most Relevant Achievement: {data['most_relevant_achievement']}") + if data.get('additional_achievements'): + prompt_parts.append(f"- Additional Achievements:\n{data['additional_achievements']}") + + # Add company research and interest if provided. + if data.get('company_research'): + prompt_parts.append(f"\nCompany Research/Understanding:\n{data['company_research']}") + if data.get('why_interested'): + prompt_parts.append(f"\nReason for Interest in Role/Company:\n{data['why_interested']}") + + # Add conditional information based on application method or additional options. + if data.get('application_method') == "Referral" and data.get('referral_name'): + prompt_parts.append(f"\nReferral: Referred by {data['referral_name']}{' (' + data['referral_relationship'] + ')' if data.get('referral_relationship') else ''}") + + if data.get('include_salary') and data.get('salary_expectations'): + prompt_parts.append(f"\nSalary Expectations: {data['salary_expectations']}") + + if data.get('include_availability') and data.get('availability'): + prompt_parts.append(f"\nAvailability: {data['availability']}") + + if data.get('include_relocation') and data.get('relocation_info'): + prompt_parts.append(f"\nRelocation Information: {data['relocation_info']}") + + if data.get('include_closing') and data.get('closing_statement'): + prompt_parts.append(f"\nCustom Closing Statement: {data['closing_statement']}") + + + # Add specific instructions based on cover letter subtype. + # These instructions guide the LLM on the specific focus for this type of letter. + if subtype == "standard": + prompt_parts.append("\nInstruction: This is a standard cover letter. Focus on clearly matching the applicant's qualifications to the job requirements in a professional manner.") + elif subtype == "career_change": + prompt_parts.append("\nInstruction: This is a career change cover letter. Emphasize transferable skills from previous roles and explain the motivation for this career transition. Clearly connect past experience and new skills to the requirements of the target role.") + elif subtype == "entry_level": + prompt_parts.append("\nInstruction: This is an entry-level cover letter. Focus on relevant education, coursework, projects, internships, and transferable skills. Emphasize enthusiasm, eagerness to learn, and potential for growth.") + elif subtype == "executive": + prompt_parts.append("\nInstruction: This is an executive-level cover letter. Emphasize strategic leadership experience, significant achievements with measurable results, and industry expertise. Use a confident, authoritative, and forward-looking tone.") + elif subtype == "creative": + prompt_parts.append("\nInstruction: This is a cover letter for a creative position. Use a more engaging and expressive style appropriate for a creative role while maintaining professionalism. Highlight specific creative achievements and link to the applicant's portfolio.") + elif subtype == "technical": + prompt_parts.append("\nInstruction: This is a cover letter for a technical position. Focus on specific technical skills, relevant projects, and problem-solving abilities. Use appropriate technical terminology accurately.") + elif subtype == "academic": + prompt_parts.append("\nInstruction: This is a cover letter for an academic position. Focus on research experience, teaching philosophy, publications, and contributions to the field. Use a scholarly and professional tone suitable for academia.") + elif subtype == "remote": + prompt_parts.append("\nInstruction: This is a cover letter for a remote position. Emphasize self-motivation, excellent communication skills (especially written), time management, and any prior experience working independently or in remote teams. Address how the applicant succeeds in a remote environment.") + elif subtype == "referral": + prompt_parts.append("\nInstruction: This is a referral cover letter. Mention the referral prominently and early in the letter. Explain the connection and how it aligns with the applicant's interest in the role. Still, ensure you highlight the applicant's own qualifications.") + + + # 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 professional, concise, and tailored to the specific job and company. Avoid generic language and clichés. Focus on what the applicant can offer the employer.") + + + # 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: + cover_letter_content = llm_text_gen(final_prompt) + return cover_letter_content + except Exception as e: + # Catch any errors during LLM generation and display an error message. + st.error(f"Error generating cover letter: {str(e)}") + return "Error generating cover letter. Please try again." + +def calculate_ats_score(cover_letter: str, job_description: str) -> int: + """ + Calculate a basic ATS compatibility score based on keyword matching + between the cover letter and the job description. + + Args: + cover_letter: The content of the cover letter. + job_description: The content of the job description. + + Returns: + An estimated ATS compatibility score between 0 and 100. + """ + if not job_description or not cover_letter: + # Return a neutral score if either is empty + return 50 + + # Convert to lowercase for case-insensitive matching + cover_letter_lower = cover_letter.lower() + job_description_lower = job_description.lower() + + # Define common words and punctuation to remove + common_words = {"and", "the", "a", "an", "in", "on", "at", "to", "for", "with", "by", "of", "or", "is", "are", "be", "will", "have", "has", "had", "as", "you", "we", "they", "it", "this", "that", "these", "those", "our", "your", "their", "its", "his", "her", "him", "she", "he", "them", "us", "me", "my", "mine", "yours", "theirs", "ours", "from", "about", "which", "what", "where", "when", "how", "who", "whom", "whose", "can", "could", "would", "should", "may", "might", "must", "get", "go", "do", "does", "did", "am", "is", "are", "was", "were", "been", "being", "have", "has", "had", "do", "did", "done", "say", "says", "said", "see", "sees", "saw", "seen", "make", "makes", "made", "go", "goes", "went", "gone", "come", "comes", "came", "come", "take", "takes", "took", "taken", "give", "gives", "gave", "given", "find", "finds", "found", "get", "gets", "got", "gotten", "know", "knows", "knew", "known", "think", "thinks", "thought", "take", "takes", "took", "taken", "want", "wants", "wanted", "look", "looks", "looked", "tell", "tells", "told", "use", "uses", "used", "find", "finds", "found", "ask", "asks", "asked", "work", "works", "worked", "seem", "seems", "seemed", "feel", "feels", "felt", "become", "becomes", "became", "become", "leave", "leaves", "left", "put", "puts", "put", "bring", "brings", "brought", "begin", "begins", "began", "begun", "show", "shows", "showed", "shown", "hear", "hears", "heard", "play", "plays", "played", "run", "runs", "ran", "run", "move", "moves", "moved", "like", "likes", "liked", "believe", "believes", "believed", "hold", "holds", "held", "happen", "happens", "happened", "write", "writes", "wrote", "written", "provide", "provides", "provided", "sit", "sits", "sat", "stand", "stands", "stood", "lose", "loses", "lost", "pay", "pays", "paid", "meet", "meets", "met", "include", "includes", "included", "continue", "continues", "continued", "set", "sets", "set", "learn", "learns", "learned", "change", "changes", "changed", "lead", "leads", "led", "understand", "understands", "understood", "watch", "watches", "watched", "follow", "follows", "followed", "stop", "stops", "stopped", "create", "creates", "created", "speak", "speaks", "spoke", "spoken", "read", "reads", "read", "allow", "allows", "allowed", "add", "adds", "added", "spend", "spends", "spent", "grow", "grows", "grew", "grown", "open", "opens", "opened", "walk", "walks", "walked", "win", "wins", "won", "offer", "offers", "offered", "remember", "remembers", "remembered", "love", "loves", "loved", "consider", "considers", "considered", "appear", "appears", "appeared", "buy", "buys", "bought", "wait", "waits", "waited", "serve", "serves", "served", "die", "dies", "died", "send", "sends", "sent", "build", "builds", "built", "stay", "stays", "stayed", "fall", "falls", "fell", "fallen", "cut", "cuts", "cut", "reach", "reaches", "reached", "kill", "kills", "killed", "remain", "remains", "remained"} # Expanded list + + # Extract words from job description, filter out short and common words + job_words = re.findall(r'\b[a-zA-Z]{3,}\b', job_description_lower) + job_words = [word for word in job_words if word not in common_words] + + # Count frequency of each word in job description + word_freq = Counter(job_words) + + # Get the most common words as potential keywords (top N, excluding very common words) + # Adjust N (e.g., 30) based on typical job description length + potential_keywords = [word for word, count in word_freq.most_common(40) if count > 1] # Increased N + + if not potential_keywords: + # If no meaningful keywords extracted, return a neutral score + return 50 + + # Check how many potential keywords are present in the cover letter + matches = 0 + for keyword in potential_keywords: + # Use regex to match whole words to avoid partial matches + if re.search(r'\b' + re.escape(keyword) + r'\b', cover_letter_lower): + matches += 1 + + # Calculate initial score based on percentage of keywords matched + score = (matches / len(potential_keywords)) * 100 + + # Adjust score based on other factors relevant to ATS: + + # Length factor (ATS prefers concise but not too short) + word_count = len(cover_letter.split()) + if word_count < 150: # Too short + score -= 15 + elif word_count > 500: # Potentially too long + score -= 10 + # Add a bonus for being within a good range (e.g., 200-400 words) + elif 200 <= word_count <= 400: + score += 5 + + + # Contact information factor (essential for ATS) + # Check for presence of key contact info indicators + contact_indicators = ["phone", "email", "linkedin"] + contact_score_bonus = 0 + for indicator in contact_indicators: + if indicator in cover_letter_lower or any(indicator in val.lower() for key, val in metadata.items() if isinstance(val, str)): # Check metadata too + contact_score_bonus += 5 + score += min(contact_score_bonus, 15) # Cap the contact bonus + + + # Formatting factor (simple check for standard elements) + # Check for salutation and closing + if re.search(r'\bdear\b', cover_letter_lower[:100]) and re.search(r'\b(sincerely|regards)\b', cover_letter_lower[-100:]): + score += 5 + + # Check for subject line presence (important for email applications) + if "subject:" in cover_letter_lower[:50]: + score += 3 + + + # Cap the score between 0 and 100 + score = max(0, min(score, 100)) + + return round(score) + + +def get_icon_for_subtype(subtype: str) -> str: + """Maps a cover letter subtype ID to a relevant emoji icon.""" + icons = { + "standard": "📝", + "career_change": "🔄", + "entry_level": "🌱", + "executive": "👔", + "creative": "🎨", + "technical": "💻", + "academic": "🎓", + "remote": "🏠", + "referral": "👥" + } + return icons.get(subtype, "📄") # Default icon + +def get_name_for_subtype(subtype: str) -> str: + """Maps a cover letter subtype ID to its display name.""" + names = { + "standard": "Standard Cover Letter", + "career_change": "Career Change Cover Letter", + "entry_level": "Entry Level Cover Letter", + "executive": "Executive Cover Letter", + "creative": "Creative Cover Letter", + "technical": "Technical Cover Letter", + "academic": "Academic Cover Letter", + "remote": "Remote Position Cover Letter", + "referral": "Referral Cover Letter" + } + return names.get(subtype, "Cover Letter") # Default name + +def get_description_for_subtype(subtype: str) -> str: + """Maps a cover letter subtype ID to a brief description.""" + descriptions = { + "standard": "A general purpose cover letter suitable for most job applications.", + "career_change": "Highlight transferable skills and explain your career transition.", + "entry_level": "Emphasize education, internships, and potential for recent graduates or those with limited experience.", + "executive": "Showcase leadership experience and strategic vision for senior management positions.", + "creative": "Express your creative abilities while maintaining professionalism for design, writing, or marketing roles.", + "technical": "Demonstrate technical expertise and problem-solving abilities for IT, engineering, and other technical roles.", + "academic": "Highlight research experience, teaching philosophy, and scholarly contributions for positions in education and research.", + "remote": "Emphasize self-motivation, communication skills, and ability to work independently for remote positions.", + "referral": "Leverage your connection at the company and explain how it relates to your interest in the position." + } + return descriptions.get(subtype, "Create a tailored cover letter for your job application.") # Default description + +def get_fields_for_subtype(subtype: str) -> List[Dict[str, Any]]: + """ + Provides a list of input field configurations specific to each cover 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. + # Note: Many fields are common across subtypes but their *emphasis* in the prompt changes. + # This function defines the *inputs* available on the form. + common_fields = [] # No fields are strictly *only* common, all can be emphasized differently + + if subtype == "standard": + return common_fields # Standard uses the general fields available in the form structure + + elif subtype == "career_change": + # No specific *extra* fields needed, but the prompt emphasizes transferable skills and transition explanation. + return common_fields + + elif subtype == "entry_level": + # No specific *extra* fields needed, but the prompt emphasizes education, projects, and potential. + return common_fields + + elif subtype == "executive": + # No specific *extra* fields needed, but the prompt emphasizes leadership and strategic achievements. + return common_fields + + elif subtype == "creative": + # No specific *extra* fields needed, but the prompt emphasizes portfolio and creative process. + return common_fields + + elif subtype == "technical": + # No specific *extra* fields needed, but the prompt emphasizes technical skills and projects. + return common_fields + + elif subtype == "academic": + # No specific *extra* fields needed, but the prompt emphasizes research, teaching, and publications. + return common_fields + + elif subtype == "remote": + # No specific *extra* fields needed, but the prompt emphasizes remote work skills. + return common_fields + + elif subtype == "referral": + # No specific *extra* fields needed, but the prompt emphasizes the referral. + return common_fields + + # Default fields if subtype is not recognized or no specific fields are defined. + # In this cover letter module, the main form structure already defines the core fields, + # so this fallback is less critical but included for robustness. + return [] # Returning an empty list means no extra fields are added dynamically + + +def get_tones_for_subtype(subtype: str) -> List[str]: + """Maps a cover letter subtype ID to a list of suggested tones.""" + tones = { + "standard": ["Professional", "Confident", "Enthusiastic", "Formal"], + "career_change": ["Professional", "Confident", "Determined", "Enthusiastic"], + "entry_level": ["Enthusiastic", "Eager", "Professional", "Confident"], + "executive": ["Confident", "Authoritative", "Strategic", "Professional"], + "creative": ["Enthusiastic", "Expressive", "Professional", "Passionate"], + "technical": ["Professional", "Analytical", "Precise", "Confident"], + "academic": ["Scholarly", "Professional", "Analytical", "Clear"], + "remote": ["Professional", "Self-motivated", "Communicative", "Reliable"], + "referral": ["Professional", "Enthusiastic", "Connected", "Confident"] + } + # Return the list of tones for the subtype, or a default list if not found. + return tones.get(subtype, ["Professional", "Confident", "Enthusiastic"]) + +# 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() diff --git a/lib/ai_writers/ai_letter_writer/formal_letters.py b/lib/ai_writers/ai_letter_writer/formal_letters.py new file mode 100644 index 00000000..07df6101 --- /dev/null +++ b/lib/ai_writers/ai_letter_writer/formal_letters.py @@ -0,0 +1,1184 @@ +""" +Formal Letters Module + +This module provides a Streamlit interface for generating various types of formal letters +using AI assistance. It collects user inputs specific to the chosen formal 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 formal letters.""" + # Basic HTML structure with inline styles for preview + formatted_paragraphs = "".join(f"

{p.strip()}

" for p in content.split("\n\n") if p.strip()) + return f""" +
+
{metadata.get('date', 'Date')}
+
Subject: {metadata.get('subject', 'No Subject')}
+
{metadata.get('salutation', 'Dear Recipient,')}
+
{formatted_paragraphs if formatted_paragraphs else "

Letter content goes here...

"}
+
{metadata.get('complimentary_close', 'Sincerely,')}
+
{metadata.get('sender_name', 'Sender Name')}
+
{metadata.get('sender_title', 'Sender Title')}
+
+ """ + +def analyze_letter_tone(content: str) -> Dict[str, float]: + """Placeholder: Returns dummy tone analysis.""" + # Returns scores between 0.0 and 1.0 + return {"professional": 0.9, "formal": 0.85, "objective": 0.7} + +def check_formality(content: str) -> float: + """Placeholder: Returns a dummy formality score (0.0 to 1.0).""" + return 0.88 # Example: 88% 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": 45.0, # Dummy score for formal letters + "reading_level": "Difficult", # 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 "passive voice" in content.lower(): + return ["Suggestion: Consider using more active voice for clarity and impact."] + elif len(content.split('.')) < 5: + return ["Suggestion: The letter seems very short. Ensure all necessary details are included."] + else: + return ["Suggestion: Double-check for any jargon that the recipient might not understand."] + +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": ["Sender Info", "Date", "Recipient Info", "Subject", "Salutation", "Body", "Closing", "Signature"], "guidance": "Follow standard formal letter practices."} + +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"Subject: Generated Formal Letter Preview\n\nDear [Generated Recipient Name],\n\nThis is a sample formal 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, formality, tone, and language complexity.]\n\nSincerely,\n[Generated Your Name]" + +# --- End Placeholder Functions --- + + +def write_letter(): + """ + Main function for the Formal Letters interface. Sets up the Streamlit page + and handles navigation between subtype selection and the letter form. + """ + + # Page title and description + st.title("📝 Formal Letter Writer") + st.markdown(""" + Create professional formal letters for business, academic, and official purposes. Select a letter type below to get started. + """) + + # Initialize Streamlit session state variables specific to the formal module. + # These variables persist across reruns and store the user's progress and data. + if "formal_letter_subtype" not in st.session_state: + st.session_state.formal_letter_subtype = None # Stores the ID of the selected formal letter type + if "formal_letter_generated" not in st.session_state: + st.session_state.formal_letter_generated = False # Flag to indicate if a letter has been generated + if "formal_letter_content" not in st.session_state: + st.session_state.formal_letter_content = None # Stores the generated letter content + if "formal_letter_metadata" not in st.session_state: + st.session_state.formal_letter_metadata = {} # Stores metadata like sender/recipient info + if "formal_letter_form_data" not in st.session_state: + st.session_state.formal_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.formal_letter_subtype is not None: + if st.button("← Back to Formal 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.formal_letter_subtype = None + st.session_state.formal_letter_generated = False + st.session_state.formal_letter_content = None + st.session_state.formal_letter_metadata = {} + st.session_state.formal_letter_form_data = {} + st.rerun() # Rerun the app to update the UI based on the changed state + + # Main navigation logic within the formal module. + # If no subtype is selected, show the selection grid. Otherwise, show the form for the selected subtype. + if st.session_state.formal_letter_subtype is None: + # Display formal letter type selection if no subtype is selected + display_formal_letter_types() + else: + # Display the interface form for the selected formal letter subtype + display_formal_letter_form(st.session_state.formal_letter_subtype) + + +def display_formal_letter_types(): + """ + Displays the formal letter type selection interface using a grid of styled buttons. + Each button represents a specific type of formal letter the user can choose to write. + """ + + st.markdown("## Select Formal Letter Type") + + # Define formal letter types with their details (ID, Name, Icon, Description, Color) + # This list is used to generate the selection buttons. + formal_letter_types = [ + { + "id": "application", + "name": "Application Letter", + "icon": "📋", + "description": "Apply for a job, program, or opportunity", + "color": "#1976D2" # Blue + }, + { + "id": "complaint", + "name": "Complaint Letter", + "icon": "⚠️", + "description": "Express dissatisfaction with a product or service", + "color": "#D32F2F" # Red + }, + { + "id": "request", + "name": "Request Letter", + "icon": "🙋", + "description": "Make a formal request for information or action", + "color": "#388E3C" # Green + }, + { + "id": "recommendation", + "name": "Recommendation Letter", + "icon": "👍", + "description": "Recommend someone for a position or opportunity", + "color": "#7B1FA2" # Purple + }, + { + "id": "resignation", + "name": "Resignation Letter", + "icon": "🚪", + "description": "Formally resign from a position", + "color": "#455A64" # Blue Grey + }, + { + "id": "inquiry", + "name": "Inquiry Letter", + "icon": "❓", + "description": "Request information about a product, service, or opportunity", + "color": "#0097A7" # Teal + }, + { + "id": "authorization", + "name": "Authorization Letter", + "icon": "✅", + "description": "Grant permission for someone to act on your behalf", + "color": "#FF5722" # Deep Orange + }, + { + "id": "appeal", + "name": "Appeal Letter", + "icon": "🔄", + "description": "Appeal a decision or request reconsideration", + "color": "#FFA000" # Amber + }, + { + "id": "introduction", + "name": "Introduction Letter", + "icon": "🤝", + "description": "Introduce yourself or your organization", + "color": "#5D4037" # Brown + } + ] + + # Inject custom CSS to style the Streamlit buttons to look like cards. + # This provides a visually appealing selection grid. + st.markdown(""" + + """, 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(formal_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

{letter_type_config['description']}

", + key=f"btn_formal_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.formal_letter_subtype = letter_type_config['id'] + # Clear previous data related to letter generation when selecting a new type + st.session_state.formal_letter_generated = False + st.session_state.formal_letter_content = None + st.session_state.formal_letter_metadata = {} + st.session_state.formal_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 formal_letter_types: + button_styles += f""" + div.stButton > button[data-testid="stButton"][kind="primary"][sf-key*="btn_formal_select_{letter_type_config['id']}"] {{ + background-color: {letter_type_config['color']}; + }} + div.stButton > button[data-testid="stButton"][kind="primary"][sf-key*="btn_formal_select_{letter_type_config['id']}"]:hover {{ + background-color: {letter_type_config['color']}D9; /* Slightly darker on hover */ + }} + """ + st.markdown(f"", unsafe_allow_html=True) + + +def display_formal_letter_form(subtype: str): + """ + Displays the form for the selected formal letter subtype. This includes + input fields specific to the subtype, contact information fields, + tone and style options, and tabs for previewing and analyzing the generated letter. + + Args: + subtype: The ID string of the selected formal 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("formal", 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"formal_letter_form_{subtype}"): + # Create tabs to organize the form sections. + tab1, tab2, tab3 = st.tabs(["Letter Details", "Contact Information", "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.formal_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.formal_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']}") + elif field["type"] == "number": + # Use the default value from session state or the field config's min value + default_number_value = st.session_state.formal_letter_form_data.get(field["id"], field.get("min", 0)) + form_data[field["id"]] = st.number_input(field["label"], min_value=field.get("min", 0), value=default_number_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: + # Slider for Formality Level, using session state default. + formality_options = ["Standard Formal", "Very Formal", "Extremely Formal"] + default_formality_level = st.session_state.formal_letter_form_data.get("formality_level", "Standard Formal") + form_data["formality_level"] = st.select_slider( + "Formality Level", + options=formality_options, + value=default_formality_level, + help="Select the desired level of formality for your letter.", + key=f"{subtype}_formality_level" + ) + + # Selectbox for Tone, using subtype-specific tones and session state default. + tone_options = get_tones_for_subtype(subtype) + default_tone = st.session_state.formal_letter_form_data.get("tone", tone_options[0] if tone_options else "Professional") + form_data["tone"] = st.selectbox( + "Tone", + tone_options, + index=tone_options.index(default_tone) if default_tone in tone_options else 0, + help="Select the overall tone for your letter.", + key=f"{subtype}_tone" + ) + + with col2: + # Slider for Length, using session state default. + length_options = ["Brief", "Standard", "Detailed"] + default_length = st.session_state.formal_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" + ) + + # Slider for Language Complexity, using session state default. + complexity_options = ["Simple", "Moderate", "Advanced"] + default_language_complexity = st.session_state.formal_letter_form_data.get("language_complexity", "Moderate") + form_data["language_complexity"] = st.select_slider( + "Language Complexity", + options=complexity_options, + value=default_language_complexity, + help="Select the complexity level of language used in the letter.", + key=f"{subtype}_language_complexity" + ) + + # Section for adding additional options like references. + st.markdown("### Additional Options") + + # Checkbox and textarea for including references. + default_include_references = st.session_state.formal_letter_form_data.get("include_references", True) + include_references = st.checkbox("Include references to relevant documents, policies, or previous communications", value=default_include_references, help="Check to include specific references.", key=f"{subtype}_include_references") + form_data["references"] = None # Initialize to None + if include_references: + default_references = st.session_state.formal_letter_form_data.get("references", "") + form_data["references"] = st.text_area( + "References Details", + value=default_references, + height=100, + help="Mention any relevant documents, policies, or previous communications.", + placeholder="e.g., Regarding your email dated June 15, 2023, about the project timeline...", + key=f"{subtype}_references" + ) + + # Advanced options expander for legal/confidentiality notices. + with st.expander("Advanced Options"): + # Checkbox and textarea for including a legal disclaimer. + default_include_legal_disclaimer = st.session_state.formal_letter_form_data.get("include_legal_disclaimer", False) + include_legal_disclaimer = st.checkbox("Include legal disclaimer", value=default_include_legal_disclaimer, help="Add a legal disclaimer to your letter.", key=f"{subtype}_include_legal_disclaimer") + form_data["legal_disclaimer"] = None # Initialize to None + if include_legal_disclaimer: + default_legal_disclaimer = st.session_state.formal_letter_form_data.get("legal_disclaimer", "") + form_data["legal_disclaimer"] = st.text_area( + "Legal Disclaimer Text", + value=default_legal_disclaimer, + height=100, + help="Enter the text for the legal disclaimer.", + placeholder="e.g., This letter is without prejudice to any rights or remedies available to [Company Name]...", + key=f"{subtype}_legal_disclaimer" + ) + + # Checkbox and textarea for including a confidentiality notice. + default_include_confidentiality_notice = st.session_state.formal_letter_form_data.get("include_confidentiality_notice", False) + include_confidentiality_notice = st.checkbox("Include confidentiality notice", value=default_include_confidentiality_notice, help="Add a confidentiality notice to your letter.", key=f"{subtype}_include_confidentiality_notice") + form_data["confidentiality_notice"] = None # Initialize to None + if include_confidentiality_notice: + default_confidentiality_notice = st.session_state.formal_letter_form_data.get("confidentiality_notice", "") + form_data["confidentiality_notice"] = st.text_area( + "Confidentiality Notice Text", + value=default_confidentiality_notice, + height=100, + help="Enter the text for the confidentiality notice.", + placeholder="e.g., The information contained in this letter is confidential and intended only for the recipient...", + key=f"{subtype}_confidentiality_notice" + ) + + + # --- Tab 2: Contact Information --- + with tab2: + # Section for sender and recipient contact information. + col3, col4 = st.columns(2) + + with col3: + st.markdown("### Sender Information") + # Input fields for sender's contact details, populated from session state. + form_data["sender_name"] = st.text_input("Your Full Name", value=st.session_state.formal_letter_form_data.get("sender_name", ""), help="Your full name as the sender.", key=f"{subtype}_sender_name") + form_data["sender_title"] = st.text_input("Your Title/Position", value=st.session_state.formal_letter_form_data.get("sender_title", ""), help="Your job title or position.", key=f"{subtype}_sender_title") + form_data["sender_organization"] = st.text_input("Your Organization/Company (Optional)", value=st.session_state.formal_letter_form_data.get("sender_organization", ""), help="The name of your organization or company, if applicable.", key=f"{subtype}_sender_organization") + form_data["sender_address"] = st.text_area("Your Address", value=st.session_state.formal_letter_form_data.get("sender_address", ""), height=100, help="Your full mailing address.", key=f"{subtype}_sender_address") + form_data["sender_phone"] = st.text_input("Your Phone Number (Optional)", value=st.session_state.formal_letter_form_data.get("sender_phone", ""), help="Your contact phone number.", key=f"{subtype}_sender_phone") + form_data["sender_email"] = st.text_input("Your Email Address (Optional)", value=st.session_state.formal_letter_form_data.get("sender_email", ""), help="Your contact email address.", key=f"{subtype}_sender_email") + + with col4: + st.markdown("### Recipient Information") + # Input fields for recipient's contact details, populated from session state. + form_data["recipient_name"] = st.text_input("Recipient's Full Name", value=st.session_state.formal_letter_form_data.get("recipient_name", ""), help="The full name of the recipient (if known).", key=f"{subtype}_recipient_name") + form_data["recipient_title"] = st.text_input("Recipient's Title/Position (Optional)", value=st.session_state.formal_letter_form_data.get("recipient_title", ""), help="The recipient's job title or position (if known).", key=f"{subtype}_recipient_title") + form_data["recipient_organization"] = st.text_input("Recipient's Organization/Company", value=st.session_state.formal_letter_form_data.get("recipient_organization", ""), help="The name of the recipient's organization or company.", key=f"{subtype}_recipient_organization") + form_data["recipient_address"] = st.text_area("Recipient's Address", value=st.session_state.formal_letter_form_data.get("recipient_address", ""), height=100, help="The recipient's full mailing address.", key=f"{subtype}_recipient_address") + + # Optional recipient contact information in an expander. + with st.expander("Additional Recipient Information (Optional)"): + form_data["recipient_phone"] = st.text_input("Recipient's Phone Number (Optional)", value=st.session_state.formal_letter_form_data.get("recipient_phone", ""), help="Recipient's contact phone number.", key=f"{subtype}_recipient_phone") + form_data["recipient_email"] = st.text_input("Recipient's Email Address (Optional)", value=st.session_state.formal_letter_form_data.get("recipient_email", ""), help="Recipient's contact email address.", key=f"{subtype}_recipient_email") + + # Section for letter formatting options. + st.markdown("### Letter Format") + format_options = ["Full Block", "Modified Block", "Semi-Block"] + default_letter_format = st.session_state.formal_letter_form_data.get("letter_format", "Full Block") + form_data["letter_format"] = st.selectbox( + "Format Style", + format_options, + index=format_options.index(default_letter_format) if default_letter_format in format_options else 0, + help="Select the standard formal letter format style.", + key=f"{subtype}_letter_format" + ) + + default_include_subject_line = st.session_state.formal_letter_form_data.get("include_subject_line", True) + include_subject_line = st.checkbox("Include subject line", value=default_include_subject_line, help="Include a clear subject line.", key=f"{subtype}_include_subject_line") + form_data["subject_line"] = None # Initialize to None + if include_subject_line: + default_subject_line = st.session_state.formal_letter_form_data.get("subject_line", "") + form_data["subject_line"] = st.text_input( + "Subject Line Text", + value=default_subject_line, + help="Enter the text for the subject line.", + placeholder="e.g., Application for Marketing Manager Position (Ref: JOB-2023-45)", + key=f"{subtype}_subject_line" + ) + + default_include_reference_number = st.session_state.formal_letter_form_data.get("include_reference_number", False) + include_reference_number = st.checkbox("Include reference number", value=default_include_reference_number, help="Include a reference number for tracking.", key=f"{subtype}_include_reference_number") + form_data["reference_number"] = None # Initialize to None + if include_reference_number: + default_reference_number = st.session_state.formal_letter_form_data.get("reference_number", "") + form_data["reference_number"] = st.text_input( + "Reference Number Text", + value=default_reference_number, + help="Enter the reference number.", + placeholder="e.g., REF-2023-123", + key=f"{subtype}_reference_number" + ) + + # --- Tab 3: Preview & Export --- + with tab3: + # Instructions for the user before generation. + if not st.session_state.formal_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.formal_letter_form_data = form_data.copy() + + # Prepare metadata specifically for the formatter and analysis functions. + # This includes structured contact info, dates, subject, etc. + metadata = { + "sender_name": form_data.get("sender_name", ""), + "sender_title": form_data.get("sender_title", ""), + "sender_organization": form_data.get("sender_organization", ""), + "sender_address": form_data.get("sender_address", ""), + "sender_phone": form_data.get("sender_phone", ""), + "sender_email": form_data.get("sender_email", ""), + "recipient_name": form_data.get("recipient_name", ""), + "recipient_title": form_data.get("recipient_title", ""), + "recipient_organization": form_data.get("recipient_organization", ""), + "recipient_address": form_data.get("recipient_address", ""), + "recipient_phone": form_data.get("recipient_phone", ""), + "recipient_email": form_data.get("recipient_email", ""), + "date": datetime.datetime.now().strftime("%B %d, %Y"), # Use current date for the letter + "letter_format": form_data.get("letter_format", "Full Block"), + "subject": form_data.get('subject_line') if form_data.get('include_subject_line') else "", # Include subject in metadata for formatter + "reference_number": form_data.get('reference_number') if form_data.get('include_reference_number') else "", # Include reference in metadata + } + # Determine salutation based on recipient name/title preference + recipient_display_name = metadata.get("recipient_name") + recipient_display_title = metadata.get("recipient_title") + if recipient_display_name and recipient_display_title: + metadata["salutation"] = f"Dear {recipient_display_title} {recipient_display_name}:" + elif recipient_display_name: + metadata["salutation"] = f"Dear {recipient_display_name}:" + else: + metadata["salutation"] = "Dear Sir/Madam:" # Fallback salutation + + # Determine complimentary close based on formality + metadata["complimentary_close"] = "Sincerely," # Standard formal close + + + st.session_state.formal_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") or not form_data.get("recipient_organization"): + st.error("Please provide at least your name, the recipient's name, and the recipient's organization.") + else: + # Display a spinner while the AI generates the letter. + with st.spinner("Generating your formal letter..."): + # Combine all necessary data into a single dictionary for the generation function. + # This includes both form data and metadata. + # Note: formality_level, tone, length, language_complexity are already in form_data + generation_data = { + "subtype": subtype, + **form_data, # Includes all collected form inputs + **metadata # Includes structured sender/recipient/date/format info + } + + # Call the letter generation function with the combined data. + letter_content = generate_formal_letter(generation_data) + + # Store the generated letter content and update the generated flag. + st.session_state.formal_letter_content = letter_content + st.session_state.formal_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.formal_letter_generated and st.session_state.formal_letter_content is not None: + letter_content = st.session_state.formal_letter_content + metadata = st.session_state.formal_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="formal" to the formatter. + html_preview = get_letter_preview_html(letter_content, metadata, letter_type="formal") + st.markdown(html_preview, unsafe_allow_html=True) + + # Download button for the plain text version of the letter. + file_name_suffix = metadata.get('recipient_organization', 'formal').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, "formal") # Pass "formal" 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.formal_letter_generated = False + st.session_state.formal_letter_content = None # Clear generated content + # st.session_state.formal_letter_form_data is already populated from the form submit + st.rerun() # Rerun to show the form with previous inputs + + +def generate_formal_letter(data: Dict[str, Any]) -> str: + """ + Generates a formal 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") + formality_level = data.get("formality_level", "Standard Formal") + tone = data.get("tone", "Professional") + length = data.get("length", "Standard") + language_complexity = data.get("language_complexity", "Moderate") + + # Get template guidance and structure to include in the prompt. + template = get_template_by_type("formal", subtype) + template_guidance = template.get("guidance", "Follow standard formal letter practices.") + template_structure = template.get("structure", ["Sender Info", "Date", "Recipient Info", "Subject", "Salutation", "Body", "Closing", "Signature"]) + + + # 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()}, {formality_level.lower()} {get_name_for_subtype(subtype)} letter with a {tone.lower()} tone using {language_complexity.lower()} language complexity.", + f"Purpose: {get_description_for_subtype(subtype)}", + f"Recipient: {data.get('recipient_name', '')}, {data.get('recipient_title', '')} at {data.get('recipient_organization', '')}", + f"Sender: {data.get('sender_name', '')}, {data.get('sender_title', '')} at {data.get('sender_organization', '')}", + f"Date: {data.get('date', '')}", + f"Desired Format Style: {data.get('letter_format', 'Full Block')}", + ] + + # Add subject line if provided + if data.get('include_subject_line') and data.get('subject_line'): + prompt_parts.append(f"Subject: {data['subject_line']}") + + # Add reference number if provided + if data.get('include_reference_number') and data.get('reference_number'): + prompt_parts.append(f"Reference Number: {data['reference_number']}") + + + # 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 additional options if included. + if data.get('include_references') and data.get('references'): + prompt_parts.append(f"Include references: {data['references']}") + + if data.get('include_legal_disclaimer') and data.get('legal_disclaimer'): + prompt_parts.append(f"Include legal disclaimer: {data['legal_disclaimer']}") + + if data.get('include_confidentiality_notice') and data.get('confidentiality_notice'): + prompt_parts.append(f"Include confidentiality notice: {data['confidentiality_notice']}") + + # 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 professional, clear, and appropriate for the formal context.") + + + # 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 formal letter subtype ID to a relevant emoji icon.""" + icons = { + "application": "📋", + "complaint": "⚠️", + "request": "🙋", + "recommendation": "👍", + "resignation": "🚪", + "inquiry": "❓", + "authorization": "✅", + "appeal": "🔄", + "introduction": "🤝" + } + return icons.get(subtype, "📝") # Default icon + +def get_name_for_subtype(subtype: str) -> str: + """Maps a formal letter subtype ID to its display name.""" + names = { + "application": "Application Letter", + "complaint": "Complaint Letter", + "request": "Request Letter", + "recommendation": "Recommendation Letter", + "resignation": "Resignation Letter", + "inquiry": "Inquiry Letter", + "authorization": "Authorization Letter", + "appeal": "Appeal Letter", + "introduction": "Introduction Letter" + } + return names.get(subtype, "Formal Letter") # Default name + +def get_description_for_subtype(subtype: str) -> str: + """Maps a formal letter subtype ID to a brief description.""" + descriptions = { + "application": "Apply for a job, program, or opportunity with a professional application letter.", + "complaint": "Express dissatisfaction with a product or service in a formal and effective manner.", + "request": "Make a formal request for information, assistance, or action.", + "recommendation": "Recommend someone for a position or opportunity with a professional endorsement.", + "resignation": "Formally resign from a position while maintaining professional relationships.", + "inquiry": "Request information about a product, service, or opportunity in a formal manner.", + "authorization": "Grant permission for someone to act on your behalf with a formal authorization.", + "appeal": "Appeal a decision or request reconsideration with a persuasive formal letter.", + "introduction": "Introduce yourself or your organization with a professional introduction letter." + } + return descriptions.get(subtype, "Create a formal 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 formal 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 == "application": + return [ + { + "id": "position", + "label": "Position/Opportunity", + "type": "text", + "help": "What specific position, program, or opportunity are you applying for?" + }, + { + "id": "source", + "label": "Where You Found the Opportunity (Optional)", + "type": "text", + "help": "Where did you learn about this opportunity? (e.g., company website, job board, referral)" + }, + { + "id": "qualifications", + "label": "Key Qualifications", + "type": "textarea", + "help": "List your key qualifications and skills that match the requirements of this position." + }, + { + "id": "experience", + "label": "Relevant Experience", + "type": "textarea", + "help": "Describe your relevant work experience, projects, or academic background." + } + ] + elif subtype == "complaint": + return [ + { + "id": "product_service", + "label": "Product or Service Involved", + "type": "text", + "help": "What product or service is the subject of your complaint?" + }, + { + "id": "date_of_incident", + "label": "Date of Purchase or Incident", + "type": "date", # Using date type for date input + "help": "When did you purchase the product or when did the incident occur?" + }, + { + "id": "order_reference", + "label": "Order/Reference Number (Optional)", + "type": "text", + "help": "Include any relevant order numbers, account numbers, or reference IDs." + }, + { + "id": "complaint_nature", + "label": "Nature of Complaint", + "type": "textarea", + "help": "Describe the issue clearly, factually, and in detail. Include specific dates, times, and names if applicable." + }, + { + "id": "desired_resolution", + "label": "Desired Resolution", + "type": "textarea", + "help": "Clearly state what outcome you are seeking to resolve this complaint (e.g., full refund, replacement, repair, specific action)." + } + ] + elif subtype == "request": + return [ + { + "id": "request_type", + "label": "Type of Request", + "type": "text", + "help": "What type of formal request are you making? (e.g., Request for Information, Request for Meeting, Request for Document)" + }, + { + "id": "request_details", + "label": "Specific Request Details", + "type": "textarea", + "help": "Provide all necessary details about what you are requesting." + }, + { + "id": "request_reason", + "label": "Reason or Justification for Request", + "type": "textarea", + "help": "Clearly explain why you are making this request and its importance." + }, + { + "id": "deadline", + "label": "Deadline for Response/Action (if applicable)", + "type": "date", # Using date type for date input + "help": "Is there a specific date by which you need a response or action?" + } + ] + elif subtype == "recommendation": + return [ + { + "id": "recommendee", + "label": "Person Being Recommended (Full Name)", + "type": "text", + "help": "Enter the full name of the person you are recommending." + }, + { + "id": "position", + "label": "Position/Opportunity Being Recommended For", + "type": "text", + "help": "What specific position, program, or opportunity are you recommending them for?" + }, + { + "id": "relationship", + "label": "Your Relationship to the Recommendee", + "type": "text", + "help": "Describe your professional or academic relationship (e.g., former manager, professor, colleague)." + }, + { + "id": "relationship_duration", + "label": "Duration of Relationship", + "type": "text", + "help": "How long have you known the person in this capacity? (e.g., 3 years, from 2018 to 2022)" + }, + { + "id": "strengths", + "label": "Key Strengths and Qualities", + "type": "textarea", + "help": "Highlight the most relevant strengths and qualities of the person being recommended." + }, + { + "id": "achievements", + "label": "Specific Achievements or Contributions", + "type": "textarea", + "help": "Provide concrete examples of their achievements, contributions, or performance." + } + ] + elif subtype == "resignation": + return [ + { + "id": "current_position", + "label": "Your Current Position", + "type": "text", + "help": "What is your current job title?" + }, + { + "id": "last_day", + "label": "Your Last Working Day", + "type": "date", # Using date type for date input + "help": "Specify your intended last day of employment." + }, + { + "id": "resignation_reason", + "label": "Reason for Resignation (Optional)", + "type": "textarea", + "help": "You may choose to provide a brief, professional reason for leaving (e.g., pursuing a new opportunity, personal reasons)." + }, + { + "id": "transition_plan", + "label": "Offer of Assistance with Transition (Optional)", + "type": "textarea", + "help": "Offer to assist with the transition of your responsibilities." + }, + { + "id": "gratitude", + "label": "Express Gratitude (Optional)", + "type": "textarea", + "help": "Express thanks for the opportunity and experience gained." + } + ] + elif subtype == "inquiry": + return [ + { + "id": "inquiry_subject", + "label": "Inquiry Subject", + "type": "text", + "help": "What is the main subject of your inquiry?" + }, + { + "id": "background_info", + "label": "Relevant Background Information (Optional)", + "type": "textarea", + "help": "Provide any necessary context for your inquiry." + }, + { + "id": "specific_questions", + "label": "Specific Questions", + "type": "textarea", + "help": "List your questions clearly and concisely, perhaps using bullet points." + }, + { + "id": "response_deadline", + "label": "Deadline for Response (if applicable)", + "type": "date", # Using date type for date input + "help": "By when do you need the information?" + } + ] + elif subtype == "authorization": + return [ + { + "id": "authorized_person", + "label": "Person Being Authorized (Full Name)", + "type": "text", + "help": "Enter the full name of the person you are authorizing." + }, + { + "id": "authorized_person_id", + "label": "Authorized Person's Identification (Optional)", + "type": "text", + "help": "Include any identification details if necessary (e.g., ID number, employee ID)." + }, + { + "id": "authorization_purpose", + "label": "Purpose and Scope of Authorization", + "type": "textarea", + "help": "Clearly and precisely state what you are authorizing them to do on your behalf." + }, + { + "id": "authorization_duration", + "label": "Duration of Authorization", + "type": "text", # Using text for flexibility (e.g., "from date to date", "until revoked") + "help": "Specify how long this authorization is valid (e.g., 'from [Start Date] to [End Date]', 'until revoked in writing')." + }, + { + "id": "authorization_limitations", + "label": "Limitations or Restrictions (Optional)", + "type": "textarea", + "help": "Specify any limitations or restrictions on the authorized person's actions." + } + ] + elif subtype == "appeal": + return [ + { + "id": "appealed_decision", + "label": "Decision Being Appealed", + "type": "text", + "help": "Clearly identify the specific decision you are appealing." + }, + { + "id": "decision_date", + "label": "Date of Original Decision", + "type": "date", # Using date type for date input + "help": "When was the original decision made?" + }, + { + "id": "appeal_grounds", + "label": "Grounds for Appeal", + "type": "textarea", + "help": "Explain the specific reasons or arguments why you believe the decision should be overturned or reconsidered. Reference relevant policies or facts." + }, + { + "id": "supporting_evidence", + "label": "Supporting Evidence (Optional)", + "type": "textarea", + "help": "Mention any supporting documents or evidence you are providing." + }, + { + "id": "requested_outcome", + "label": "Requested Outcome", + "type": "textarea", + "help": "Clearly state what resolution you are seeking from this appeal." + } + ] + elif subtype == "introduction": + return [ + { + "id": "introduction_purpose", + "label": "Purpose of Introduction", + "type": "text", + "help": "Why are you introducing yourself or your organization to this recipient?" + }, + { + "id": "key_information", + "label": "Key Information About Yourself/Organization", + "type": "textarea", + "help": "Highlight relevant background, expertise, or services." + }, + { + "id": "collaboration_areas", + "label": "Potential Areas of Collaboration or Mutual Interest (Optional)", + "type": "textarea", + "help": "Suggest ways you could potentially collaborate or areas of shared interest." + }, + { + "id": "call_to_action", + "label": "Call to Action", + "type": "textarea", + "help": "What specific action would you like the recipient to take after reading your introduction? (e.g., schedule a meeting, visit website)" + } + ] + + # 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 formal letter." + } + ] + +def get_tones_for_subtype(subtype: str) -> List[str]: + """Maps a formal letter subtype ID to a list of suggested tones.""" + tones = { + "application": ["Professional", "Confident", "Enthusiastic", "Respectful", "Formal"], + "complaint": ["Firm", "Respectful", "Direct", "Objective", "Assertive"], + "request": ["Polite", "Clear", "Respectful", "Direct", "Appreciative"], + "recommendation": ["Supportive", "Positive", "Professional", "Enthusiastic", "Confident"], + "resignation": ["Professional", "Appreciative", "Respectful", "Positive", "Formal"], + "inquiry": ["Curious", "Professional", "Respectful", "Clear", "Formal"], + "authorization": ["Clear", "Precise", "Formal", "Direct", "Authoritative"], + "appeal": ["Persuasive", "Respectful", "Objective", "Confident", "Diplomatic"], + "introduction": ["Friendly", "Professional", "Enthusiastic", "Informative", "Engaging"] + } + # Return the list of tones for the subtype, or a default list if not found. + return tones.get(subtype, ["Professional", "Formal", "Respectful", "Clear", "Direct"]) + +# 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() diff --git a/lib/ai_writers/ai_letter_writer/letter_templates.py b/lib/ai_writers/ai_letter_writer/letter_templates.py new file mode 100644 index 00000000..6be56912 --- /dev/null +++ b/lib/ai_writers/ai_letter_writer/letter_templates.py @@ -0,0 +1,758 @@ +""" +Letter Templates Module + +This module provides structured templates and guidance for generating +different types and subtypes of letters. +Templates are defined as a nested dictionary containing 'structure' (list of sections) +and 'guidance' (a string) for each letter type and subtype. +""" + +from typing import Dict, Any, List + +# Define letter templates using a nested dictionary structure for better organization and lookup. +# The structure is {letter_type: {subtype: {template_details}}} +# 'default' subtype is used as a fallback if a specific subtype isn't found for a given type. +TEMPLATES: Dict[str, Dict[str, Dict[str, Any]]] = { + "personal": { + "congratulations": { + "structure": [ + "Greeting", + "Express congratulations", + "Acknowledge the achievement", + "Share personal thoughts/memory (optional)", + "Look to the future/well wishes", + "Closing" + ], + "guidance": "Be warm, sincere, and specific about the achievement. Express genuine happiness for the recipient. Keep the tone personal and friendly." + }, + "thank_you": { + "structure": [ + "Greeting", + "Express gratitude clearly", + "Specify what you are thankful for", + "Explain the impact or how you used it (optional)", + "Share a personal thought or memory (optional)", + "Offer reciprocation or look to the future", + "Closing" + ], + "guidance": "Be specific about what you're thankful for and how it affected you. Express sincere appreciation. Personalize the message." + }, + "sympathy": { + "structure": [ + "Greeting", + "Express sympathy for the loss", + "Acknowledge the significance of the person/situation", + "Share a positive memory or quality (optional)", + "Offer specific support (optional)", + "Closing with comforting words" + ], + "guidance": "Be gentle, compassionate, and sincere. Avoid clichés. Focus on offering genuine comfort and acknowledging the recipient's feelings." + }, + "apology": { + "structure": [ + "Greeting", + "Clearly state your apology", + "Acknowledge the specific mistake or action", + "Express understanding of the impact on the other person", + "Explain (briefly, without making excuses) what happened (optional)", + "Offer amends or suggest how to make things right", + "Assure it won't happen again", + "Closing" + ], + "guidance": "Be sincere, take full responsibility for your actions, and focus on making things right. Avoid making excuses or blaming others." + }, + "invitation": { + "structure": [ + "Greeting", + "Clearly state the invitation", + "Provide full event details (What, When, Where)", + "Explain the significance or purpose (optional)", + "Mention who else might be there (optional)", + "Request RSVP (date and contact method)", + "Express anticipation", + "Closing" + ], + "guidance": "Be clear and specific about the details (what, when, where, why). Make it easy for the person to respond." + }, + "friendship": { + "structure": [ + "Greeting", + "Express appreciation for the friendship", + "Share a recent memory or anecdote", + "Acknowledge the value of the relationship", + "Check in on them or share updates", + "Look to the future (getting together, etc.)", + "Closing" + ], + "guidance": "Be warm, personal, and specific about what you value in the friendship. Share updates and show genuine interest." + }, + "love": { + "structure": [ + "Greeting (Terms of endearment)", + "Express depth of feelings", + "Share a cherished memory or moment", + "Describe specific qualities you love and appreciate", + "Reaffirm commitment or future hopes", + "Closing (Terms of endearment)" + ], + "guidance": "Be sincere, personal, and specific about your feelings. Use sensory details and emotional language appropriate for your relationship." + }, + "encouragement": { + "structure": [ + "Greeting", + "Acknowledge the situation or challenge they face", + "Express belief in their abilities/strength", + "Offer specific words of encouragement or support", + "Remind them of past successes (optional)", + "Offer practical help (optional)", + "Look to the future with hope", + "Closing with support" + ], + "guidance": "Be positive, supportive, and specific about the person's strengths and abilities. Offer genuine encouragement and belief in them." + }, + "farewell": { + "structure": [ + "Greeting", + "State the purpose (saying goodbye)", + "Express feelings about their departure (sadness, happiness for them)", + "Share a positive memory or highlight their contribution", + "Express good wishes for their future endeavors", + "Look to staying in touch (optional)", + "Closing" + ], + "guidance": "Be warm, reflective, and forward-looking. Focus on positive memories and express genuine good wishes for their next steps." + }, + # Default personal letter template if subtype is not found + "default": { + "structure": [ + "Greeting", + "Introduction", + "Main content paragraphs", + "Closing thoughts", + "Signature" + ], + "guidance": "Be personal, authentic, and appropriate for your relationship with the recipient. The tone is typically informal to semi-formal." + } + }, + "formal": { + "application": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information (if known)", + "Subject line (Clear and concise)", + "Salutation (Formal)", + "Introduction (State position applied for and where you saw it)", + "Body paragraphs (Highlight relevant skills and experience)", + "Closing paragraph (Reiterate interest, mention enclosed resume, call to action)", + "Complimentary close (Formal)", + "Signature (Typed name)", + "Enclosures (Mention if attaching resume/portfolio)" + ], + "guidance": "Be professional, concise, and specific about your qualifications and genuine interest in the position. Tailor it to the specific job description." + }, + "complaint": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Clearly state it's a complaint)", + "Salutation (Formal)", + "Introduction (State the purpose: complaint about X service/product)", + "Problem description (Provide specific details: date, time, location, product details, names if applicable)", + "Impact statement (Explain how the problem affected you)", + "Requested resolution (Clearly state what you want: refund, replacement, action)", + "Closing paragraph (Reference attached documents, state expectation for response)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be clear, factual, and specific about the issue and your desired resolution. Maintain a respectful but firm tone. Include all relevant details." + }, + "request": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Clearly state the request)", + "Salutation (Formal)", + "Introduction (State the purpose: making a request)", + "Request details (Clearly explain what you are requesting)", + "Justification (Explain why the request is necessary or beneficial)", + "Provide supporting information (optional)", + "Closing paragraph (Express gratitude for consideration, reiterate call to action)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be clear, specific, and courteous about your request. Explain why it's important or beneficial to the recipient or organization." + }, + "recommendation": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Letter of Recommendation for [Name])", + "Salutation (Formal)", + "Introduction (State your name, title, relationship to the recommendee, and for what purpose the letter is written)", + "Body paragraphs (Describe the recommendee's qualifications, skills, and achievements with specific examples)", + "Highlight relevant experiences and contributions", + "Closing recommendation (Summarize endorsement, strongly recommend the person)", + "Complimentary close (Formal)", + "Signature (Typed name and title)" + ], + "guidance": "Be specific, positive, and credible. Use concrete examples and anecdotes to support your recommendation. Tailor it to the specific role/opportunity." + }, + "resignation": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information (Immediate supervisor/HR)", + "Subject line (Letter of Resignation - [Your Name])", + "Salutation (Formal)", + "Statement of resignation (Clearly state you are resigning)", + "Last day of employment (Specify the date)", + "Gratitude and reflection (Optional: Express thanks for the opportunity/experience)", + "Transition plan/Offer of assistance (Optional: Suggest how to ensure a smooth handover)", + "Closing paragraph (Express good wishes for the company's future)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be professional, positive (if possible), and clear about your departure and last day. Maintain a good relationship." + }, + "inquiry": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Clearly state the nature of the inquiry)", + "Salutation (Formal)", + "Introduction (State your purpose for writing - making an inquiry)", + "Inquiry details (Provide necessary context or background)", + "Specific questions (List your questions clearly, perhaps numbered)", + "Closing paragraph (Express gratitude for assistance, indicate when you need a response)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be clear, specific, and courteous about your inquiry. Organize your questions logically for easy answering." + }, + "authorization": { + "structure": [ + "Sender's contact information (The grantor of authority)", + "Date", + "Recipient's contact information (The person/entity receiving the letter)", + "Subject line (Letter of Authorization)", + "Salutation (Formal)", + "Statement of authorization (Clearly state who is authorized)", + "Authorized person's details (Full name, ID if applicable)", + "Scope of authority (Precisely define what they are authorized to do)", + "Limitations (Specify any restrictions or conditions)", + "Duration of authorization (Start and end dates, if applicable)", + "Closing paragraph (State responsibility, express confidence)", + "Complimentary close (Formal)", + "Signature (Typed name and title)" + ], + "guidance": "Be clear, specific, and precise about who is authorized, what they can do, for how long, and under what conditions. This is a legal document." + }, + "appeal": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information (Appeals committee/relevant authority)", + "Subject line (Letter of Appeal - [Your Name] - [Subject of Appeal])", + "Salutation (Formal)", + "Introduction (State your name, the decision being appealed, and the date of the decision)", + "Grounds for appeal (Clearly state the reasons why you believe the decision is incorrect)", + "Provide supporting evidence (Reference attached documents: records, photos, etc.)", + "Explain mitigating circumstances (Optional)", + "Requested outcome (Clearly state what resolution you seek)", + "Closing paragraph (Express hope for reconsideration, gratitude for time)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be respectful, factual, and persuasive. Focus on valid grounds for appeal and provide clear, supporting evidence. Maintain a formal tone." + }, + "introduction": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Introduction - [Your Name])", + "Salutation (Formal)", + "Introduction (Introduce yourself and the purpose of the letter)", + "Background information (Briefly describe your relevant background or expertise)", + "Reason for reaching out (Explain why you are introducing yourself to this specific person/entity)", + "Potential areas of collaboration or shared interest (Optional)", + "Call to action (Suggest a meeting, call, or further communication)", + "Closing paragraph (Express enthusiasm for potential connection)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be professional, informative, and engaging. Clearly explain who you are, your expertise, and why you're reaching out to them specifically." + }, + # Default formal letter template if subtype is not found + "default": { + "structure": [ + "Sender's address", + "Date", + "Recipient's address", + "Subject line", + "Salutation", + "Introduction", + "Body paragraphs", + "Closing paragraph", + "Complimentary close", + "Signature" + ], + "guidance": "Be professional, clear, and concise. Use formal language and structure. The tone is typically formal." + } + }, + "business": { + "sales": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line (Benefit-oriented)", + "Salutation", + "Attention-grabbing opening (Address a pain point or introduce a benefit)", + "Problem statement (Briefly describe the challenge the recipient faces)", + "Solution presentation (Introduce your product/service as the solution)", + "Benefits and features (Explain how your solution helps, focusing on benefits)", + "Social proof (Optional: Testimonials, case studies, data)", + "Call to action (Clearly state what you want them to do next)", + "Closing paragraph (Reiterate benefit, create urgency/incentive)", + "Complimentary close (Professional)", + "Signature (Typed name and title)", + "Enclosures (Optional: Brochure, pricing)" + ], + "guidance": "Be persuasive, customer-focused, and clear about the value proposition. Focus on benefits, not just features. Make the call to action obvious." + }, + "proposal": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line (Clear and descriptive)", + "Salutation", + "Introduction (State purpose: submitting a proposal)", + "Problem statement/Needs assessment (Demonstrate understanding of client's needs)", + "Proposed solution (Describe your solution in detail)", + "Implementation plan (Outline steps and timeline)", + "Costs and investment (Clearly state pricing and payment terms)", + "Benefits and ROI (Explain the value the client will receive)", + "Call to action (Suggest next steps: meeting, discussion)", + "Closing paragraph (Express enthusiasm, availability for questions)", + "Complimentary close (Professional)", + "Signature (Typed name and title)", + "Enclosures (Proposal document, appendix)" + ], + "guidance": "Be clear, specific, and persuasive about your solution. Focus on the client's needs and the value you provide. Structure it logically." + }, + "order": { + "structure": [ + "Letterhead (Your company)", + "Date", + "Recipient's address (Supplier)", + "Subject line (Purchase Order - [PO Number])", + "Salutation", + "Introduction (Reference quote/agreement, state purpose: placing an order)", + "Order details (Item list with quantities, descriptions, unit prices, total)", + "Delivery requirements (Shipping address, requested delivery date, shipping method)", + "Payment terms (Reference agreed terms)", + "Closing paragraph (Express expectation for timely delivery)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be clear, specific, and detailed about what you're ordering, quantities, delivery requirements, and payment terms. Include a purchase order number." + }, + "quotation": { + "structure": [ + "Letterhead (Your company)", + "Date", + "Recipient's address (Customer)", + "Subject line (Quotation for [Product/Service])", + "Salutation", + "Introduction (Reference inquiry, state purpose: providing a quotation)", + "Quotation details (List items/services, descriptions, unit prices, quantities, line totals)", + "Pricing breakdown (Mention taxes, discounts, fees separately)", + "Terms and conditions (Payment terms, delivery terms, warranty)", + "Validity period (State how long the quote is valid)", + "Next steps (How they can place an order)", + "Closing paragraph (Express hope to do business, offer further assistance)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be clear, specific, and transparent about pricing, terms, and what's included or excluded. Make it easy for the customer to understand and accept." + }, + "acknowledgment": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line (Acknowledgment of [Received Item/Request])", + "Salutation", + "Acknowledgment statement (Clearly state what you have received or are acknowledging)", + "Details of what's being acknowledged (Reference number, date, brief description)", + "Confirm understanding (Optional: Briefly restate the request/issue to show understanding)", + "Next steps (Outline what will happen next, e.g., processing order, investigating issue)", + "Timeline (Provide an estimated timeframe if possible)", + "Closing paragraph (Express gratitude, offer further assistance)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be prompt, clear, and specific about what you're acknowledging. Set clear expectations for next steps and timelines." + }, + "collection": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line (Invoice [Invoice Number] - Payment Due)", + "Salutation", + "Introduction (Reference invoice number and due date)", + "Account status (Clearly state the outstanding amount)", + "Payment request (Politely request payment)", + "Payment options (Remind them how to pay)", + "Consequences of non-payment (Optional: Briefly mention late fees or further action, depending on letter stage)", + "Call to action (Request payment by a specific date)", + "Closing paragraph (Express hope for prompt payment, offer to discuss)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be firm but professional. Clearly state the amount due, due date, and payment options. The tone may vary depending on how overdue the payment is." + }, + "adjustment": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address (Customer who made a complaint)", + "Subject line (Response to your inquiry - [Reference Number])", + "Salutation", + "Acknowledgment of complaint (Reference their communication and the issue)", + "Investigation findings (Explain the outcome of your investigation)", + "Adjustment offered (Clearly state the resolution: refund, replacement, credit, etc.)", + "Apology (Optional: Express regret for the inconvenience)", + "Preventive measures (Optional: Explain steps taken to prevent recurrence)", + "Closing paragraph (Express hope for continued business, offer further assistance)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be responsive, empathetic, and solution-oriented. Clearly explain the adjustment and any preventive measures taken." + }, + "credit": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address (Applicant)", + "Subject line (Credit Application Status - [Applicant Name])", + "Salutation", + "Introduction (Reference their credit application and the purpose of the letter)", + "Credit decision (Clearly state if credit is approved or denied)", + "If approved: Credit terms (Credit limit, payment terms, interest rates)", + "If denied: Reason for decision (Provide specific, compliant reasons)", + "Requirements (If approved: any further steps or documents needed)", + "Closing paragraph (If approved: Express welcome; If denied: Offer alternative options or appeals process)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be clear, specific, and transparent about the credit decision, terms, limits, or reasons for denial. Ensure compliance with regulations if denying credit." + }, + "follow_up": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line (Following up on [Previous Communication/Meeting])", + "Salutation", + "Reference to previous communication (Mention date, topic, or meeting)", + "Purpose of follow-up (Clearly state why you are writing again)", + "Action items/Next steps (Remind of agreed-upon actions or propose next steps)", + "Provide additional information (Optional)", + "Call to action (If applicable, e.g., request a response, schedule a meeting)", + "Closing paragraph (Reiterate interest, express anticipation)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be clear, specific, and action-oriented. Reference previous communication and clearly state the purpose of your follow-up and desired outcome." + }, + # Default business letter template if subtype is not found + "default": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line", + "Salutation", + "Introduction", + "Body paragraphs", + "Closing paragraph", + "Complimentary close", + "Signature" + ], + "guidance": "Be professional, clear, and concise. Focus on the business purpose of your letter. The tone is typically formal to semi-formal." + } + }, + "cover": { + "standard": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information (if known)", + "Subject line (Job Application - [Your Name] - [Job Title])", + "Salutation (Formal)", + "Introduction (State the position you are applying for, where you saw the advertisement, and a brief statement of enthusiasm)", + "Body paragraph 1 (Highlight skills and experience directly relevant to the job description - often 1-2 key qualifications)", + "Body paragraph 2 (Provide a specific example or anecdote demonstrating your abilities)", + "Body paragraph 3 (Connect your passion/goals to the company's mission/values - optional but effective)", + "Closing paragraph (Reiterate interest, mention enclosed resume, call to action)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be professional, specific about your most relevant qualifications, and clear about your interest in the position. Tailor every cover letter to the specific job and company." + }, + "career_change": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Job Application - [Your Name] - [Job Title])", + "Salutation", + "Introduction (State the position and acknowledge your career transition)", + "Body paragraph 1 (Highlight transferable skills from previous roles)", + "Body paragraph 2 (Explain your motivation for the career change and how your skills apply)", + "Body paragraph 3 (Demonstrate understanding of the new industry/role)", + "Closing paragraph (Reiterate enthusiasm, mention enclosed resume, call to action)", + "Complimentary close", + "Signature" + ], + "guidance": "Focus on transferable skills and explain your career transition. Connect your past experience and new skills directly to the requirements of the target role." + }, + "entry_level": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Job Application - [Your Name] - [Job Title])", + "Salutation", + "Introduction (State the position and your enthusiasm for the opportunity as a recent graduate/entrant)", + "Body paragraph 1 (Highlight relevant education, coursework, GPA if strong)", + "Body paragraph 2 (Describe relevant internships, projects, or volunteer experience)", + "Body paragraph 3 (Showcase soft skills: teamwork, communication, eagerness to learn)", + "Closing paragraph (Reiterate interest, mention attached resume, express availability for interview)", + "Complimentary close", + "Signature" + ], + "guidance": "Emphasize education, relevant internships/projects, and transferable skills gained through academic or extracurricular activities. Show strong potential and enthusiasm." + }, + "executive": { + "structure": [ + "Your contact information", + "Date", + "Recipient's contact information (Senior Executive/Board Member)", + "Subject line (Executive Application - [Your Name] - [Position])", + "Salutation (Formal)", + "Introduction (State position applying for, brief summary of executive profile)", + "Body paragraph 1 (Highlight strategic leadership experience and key achievements)", + "Body paragraph 2 (Discuss relevant industry expertise and market insights)", + "Body paragraph 3 (Describe experience in driving growth, managing teams, achieving results)", + "Closing paragraph (Reiterate interest, express desire to discuss contribution to the organization)", + "Complimentary close (Formal)", + "Signature" + ], + "guidance": "Emphasize strategic leadership experience, significant achievements with measurable results, and industry expertise. Use a confident, authoritative, and forward-looking tone." + }, + "creative": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Application - [Your Name] - [Creative Role])", + "Salutation", + "Creative introduction (Engaging hook related to the role or your passion)", + "Body paragraph 1 (Highlight relevant creative experience and skills)", + "Body paragraph 2 (Reference specific portfolio pieces or projects that showcase your style/abilities)", + "Body paragraph 3 (Describe your creative process or approach)", + "Closing paragraph (Reiterate enthusiasm, mention attached resume/portfolio link, call to action)", + "Complimentary close", + "Signature" + ], + "guidance": "Use a more engaging and expressive style appropriate for a creative role while maintaining professionalism. Highlight specific creative achievements and link to your portfolio." + }, + "technical": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Application - [Your Name] - [Technical Role])", + "Salutation (Formal)", + "Introduction (State position, source, and brief technical interest)", + "Body paragraph 1 (Highlight specific technical skills and proficiencies relevant to the job description)", + "Body paragraph 2 (Describe relevant technical projects or challenges you've solved)", + "Body paragraph 3 (Discuss problem-solving abilities and experience with relevant technologies)", + "Closing paragraph (Reiterate interest, mention attached resume, express availability for technical discussion/interview)", + "Complimentary close (Formal)", + "Signature" + ], + "guidance": "Focus on technical skills, relevant projects, and problem-solving abilities. Use appropriate technical terminology accurately." + }, + "academic": { + "structure": [ + "Your contact information", + "Date", + "Recipient's contact information (Search Committee Chair)", + "Subject line (Application for [Position] - [Your Name])", + "Salutation (Formal)", + "Introduction (State the position, the department, and express your strong interest)", + "Body paragraph 1 (Discuss your research experience, focus on key projects and contributions)", + "Body paragraph 2 (Describe your teaching philosophy and relevant teaching experience)", + "Body paragraph 3 (Mention publications, presentations, grants, and other scholarly contributions)", + "Closing paragraph (Reiterate enthusiasm for joining the faculty, express availability for interview/presentation)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Focus on research experience, teaching philosophy, publications, and contributions to the field. Use a scholarly and professional tone suitable for academia." + }, + "remote": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Remote Application - [Your Name] - [Job Title])", + "Salutation", + "Introduction (State the remote position, source, and enthusiasm for remote work)", + "Body paragraph 1 (Highlight experience working remotely or independently)", + "Body paragraph 2 (Emphasize self-management, time management, and organizational skills required for remote work)", + "Body paragraph 3 (Describe strong written and verbal communication skills, essential for remote collaboration)", + "Closing paragraph (Reiterate interest in the remote role, mention attached resume, express availability for video interview)", + "Complimentary close", + "Signature" + ], + "guidance": "Emphasize self-motivation, excellent communication skills (especially written), time management, and any prior experience working independently or in remote teams." + }, + "referral": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Referral Application - [Your Name] - [Job Title] - Referred by [Referrer's Name])", + "Salutation", + "Referral introduction (Immediately state who referred you and for what position)", + "Body paragraph 1 (Briefly explain your connection to the referrer and how you learned about the role)", + "Body paragraph 2 (Highlight key qualifications relevant to the job description)", + "Body paragraph 3 (Express strong interest in the position and the company)", + "Closing paragraph (Reiterate enthusiasm, mention attached resume, express availability for interview)", + "Complimentary close", + "Signature" + ], + "guidance": "Mention the referral prominently and early. Explain your connection to the referrer and how it aligns with your interest in the role. Still, ensure you highlight your own qualifications." + }, + # Default cover letter template if subtype is not found + "default": { + "structure": [ + "Contact information", + "Date", + "Recipient's information", + "Salutation", + "Introduction", + "Body paragraphs", + "Closing paragraph", + "Complimentary close", + "Signature" + ], + "guidance": "Be professional, specific about your qualifications, and clear about your interest in the position. Tailor your letter to the specific job and company." + } + }, + # Overall default template if letter type is not recognized + "default": { + "structure": [ + "Introduction", + "Body", + "Conclusion" + ], + "guidance": "Be clear, concise, and appropriate for your audience and purpose. This is a generic structure." + } +} + + +def get_template_by_type(letter_type: str, subtype: str = "default") -> Dict[str, Any]: + """ + Get a template for a specific letter type and subtype using a dictionary lookup. + + Args: + letter_type: Type of letter (e.g., "personal", "formal", "business", "cover"). + subtype: Subtype of letter (e.g., "congratulations", "application", "sales"). + Defaults to "default" if no subtype is specified. + + Returns: + Template dictionary with 'structure' (List[str]) and 'guidance' (str). + Returns the default template if the letter type or subtype is not found, + ensuring the return structure is always consistent. + """ + # Get templates for the specific letter type, or the overall default templates + # .get() method is used for safe dictionary access with a default fallback + type_templates = TEMPLATES.get(letter_type, TEMPLATES["default"]) + + # Get the template for the specific subtype, or the default for that letter type + # Chain .get() calls to handle cases where subtype or the type's default is missing + template = type_templates.get(subtype, type_templates.get("default", TEMPLATES["default"])) + + # Ensure the returned template always has 'structure' (as a list) and 'guidance' (as a string) keys. + # This adds robustness in case a template definition is incomplete. + if "structure" not in template or not isinstance(template["structure"], list): + # Fallback structure if missing or incorrect type + template["structure"] = ["Introduction", "Body", "Conclusion"] + # Update guidance to reflect that the structure was defaulted + template["guidance"] = "Generic template structure applied due to missing or invalid definition." + + if "guidance" not in template or not isinstance(template["guidance"], str): + # Fallback guidance if missing or incorrect type + template["guidance"] = "Generic guidance applied due to missing or invalid definition." + + + return template + +# Example usage (for testing purposes) +if __name__ == '__main__': + # Test cases to demonstrate functionality and default handling + print("--- Testing Letter Templates Module ---") + + # Test a known personal letter subtype + personal_congrats = get_template_by_type("personal", "congratulations") + print("\nPersonal Congratulations Template:") + print(f"Structure: {personal_congrats['structure']}") + print(f"Guidance: {personal_congrats['guidance']}") + + # Test a known formal letter subtype + formal_complaint = get_template_by_type("formal", "complaint") + print("\nFormal Complaint Template:") + print(f"Structure: {formal_complaint['structure']}") + print(f"Guidance: {formal_complaint['guidance']}") + + # Test a known business letter subtype + business_sales = get_template_by_type("business", "sales") + print("\nBusiness Sales Template:") + print(f"Structure: {business_sales['structure']}") + print(f"Guidance: {business_sales['guidance']}") + + # Test a known cover letter subtype + cover_entry_level = get_template_by_type("cover", "entry_level") + print("\nCover Entry Level Template:") + print(f"Structure: {cover_entry_level['structure']}") + print(f"Guidance: {cover_entry_level['guidance']}") + + # Test an unknown letter type (should fallback to overall default) + unknown_type = get_template_by_type("unknown_type", "some_subtype") + print("\nUnknown Type Template (Should be Overall Default):") + print(f"Structure: {unknown_type['structure']}") + print(f"Guidance: {unknown_type['guidance']}") + + # Test a known letter type but unknown subtype (should fallback to type's default) + personal_unknown_subtype = get_template_by_type("personal", "unknown_subtype") + print("\nPersonal Unknown Subtype Template (Should be Personal Default):") + print(f"Structure: {personal_unknown_subtype['structure']}") + print(f"Guidance: {personal_unknown_subtype['guidance']}") + + # Test with only letter type (should use type's default) + formal_default = get_template_by_type("formal") + print("\nFormal Default Template (No Subtype Specified):") + print(f"Structure: {formal_default['structure']}") + print(f"Guidance: {formal_default['guidance']}") diff --git a/lib/ai_writers/ai_letter_writer/main.py b/lib/ai_writers/ai_letter_writer/main.py new file mode 100644 index 00000000..4ec5b5cc --- /dev/null +++ b/lib/ai_writers/ai_letter_writer/main.py @@ -0,0 +1,236 @@ +""" +AI Letter Writer - Main Module + +This module provides a comprehensive interface for generating various types of letters +using AI assistance. It supports multiple letter formats, styles, and use cases. +It uses Streamlit for the user interface. +""" + +import streamlit as st +# Assuming these modules exist in a package structure +from .letter_types import ( + business_letters, + personal_letters, + formal_letters, + cover_letters, + recommendation_letters, + complaint_letters, + thank_you_letters, + invitation_letters +) +# Assuming these utility functions exist +from .utils.letter_formatter import format_letter +from .utils.letter_analyzer import analyze_letter_tone, check_formality +from .utils.letter_templates import get_template_by_type + +# Define the letter types and their properties +LETTER_TYPES_CONFIG = [ + { + "id": "business", + "name": "Business Letters", + "icon": "💼", + "description": "Professional correspondence for business contexts.", + "color": "#1E88E5", # Blue 600 + "module": business_letters + }, + { + "id": "personal", + "name": "Personal Letters", + "icon": "💌", + "description": "Heartfelt messages for friends and family.", + "color": "#43A047", # Green 600 + "module": personal_letters + }, + { + "id": "formal", + "name": "Formal Letters", + "icon": "📜", + "description": "Official correspondence for institutions and authorities.", + "color": "#5E35B1", # Deep Purple 600 + "module": formal_letters + }, + { + "id": "cover", + "name": "Cover Letters", + "icon": "📋", + "description": "Job application letters to showcase your qualifications.", + "color": "#FB8C00", # Orange 600 + "module": cover_letters + }, + { + "id": "recommendation", + "name": "Recommendation Letters", + "icon": "👍", + "description": "Endorse colleagues, students, or employees.", + "color": "#00ACC1", # Cyan 600 + "module": recommendation_letters + }, + { + "id": "complaint", + "name": "Complaint Letters", + "icon": "⚠️", + "description": "Address issues with products, services, or situations.", + "color": "#E53935", # Red 600 + "module": complaint_letters + }, + { + "id": "thank_you", + "name": "Thank You Letters", + "icon": "🙏", + "description": "Express gratitude for various occasions.", + "color": "#8E24AA", # Purple 600 + "module": thank_you_letters + }, + { + "id": "invitation", + "name": "Invitation Letters", + "icon": "🎉", + "description": "Invite people to events, interviews, or gatherings.", + "color": "#FFB300", # Amber 600 + "module": invitation_letters + } +] + +# Map letter type IDs to their modules for easy access +LETTER_MODULES_MAP = {config["id"]: config["module"] for config in LETTER_TYPES_CONFIG} + + +def initialize_session_state() -> None: + """Initializes necessary Streamlit session state variables.""" + if "letter_type" not in st.session_state: + st.session_state.letter_type = None + if "letter_subtype" not in st.session_state: + st.session_state.letter_subtype = None # Useful if a letter type has subtypes + if "generated_letter" not in st.session_state: + st.session_state.generated_letter = None + if "letter_metadata" not in st.session_state: + # Store information like sender, recipient, date, subject, tone, etc. + st.session_state.letter_metadata = {} + if "letter_input_data" not in st.session_state: + # Store user inputs for letter generation + st.session_state.letter_input_data = {} + + +def display_letter_type_selection() -> None: + """Displays the letter type selection interface using a grid of styled containers with buttons.""" + + st.markdown("## Select Letter Type") + + # Create a grid layout for the cards (3 columns) + cols = st.columns(3) + + # Display each letter type as a card with a button below it + for i, letter_type_config in enumerate(LETTER_TYPES_CONFIG): + with cols[i % 3]: + # Use markdown to create a styled container for the card appearance + st.markdown( + f""" +
+

{letter_type_config['icon']} {letter_type_config['name']}

+

{letter_type_config['description']}

+
+ """, + unsafe_allow_html=True + ) + + # Place the Streamlit button below the styled container + # Make the button expand to the width of the column for better alignment with the card + if st.button( + f"Select {letter_type_config['name']}", + key=f"btn_select_{letter_type_config['id']}", # Unique key for each button + use_container_width=True + ): + st.session_state.letter_type = letter_type_config['id'] + # Clear previous state data when selecting a new type + st.session_state.letter_subtype = None + st.session_state.generated_letter = None + st.session_state.letter_metadata = {} + st.session_state.letter_input_data = {} + st.rerun() + + +def display_letter_interface(letter_type_id: str) -> None: + """ + Displays the interface for the selected letter type by calling the + appropriate module's write function. + + Args: + letter_type_id: The ID string of the selected letter type. + """ + module = LETTER_MODULES_MAP.get(letter_type_id) + + if module: + try: + # Call the main function (e.g., write_letter or main) from the selected module + # Assuming the module has a function that renders its UI and handles generation + module.write_letter() # Assuming the function is named 'write_letter' + except AttributeError: + st.error(f"Module for '{letter_type_id}' does not have a 'write_letter' function.") + except Exception as e: + st.error(f"An error occurred while loading the interface for '{letter_type_id}': {e}") + else: + st.error(f"Letter type module '{letter_type_id}' not found in map.") + + +def write_letter() -> None: + """Main function for the AI Letter Writer interface.""" + + # Page title and description + st.title("✉️ AI Letter Writer") + st.markdown(""" + Create professional, personalized letters for any occasion. Select a letter type below to get started. + Our AI will help you craft the perfect letter with the right tone, structure, and content. + """) + + # Initialize session state on first run + initialize_session_state() + + # Back button logic - only show if a letter type is selected + if st.session_state.letter_type is not None: + if st.button("← Back to Letter Types"): + # Reset session state to return to selection + st.session_state.letter_type = None + st.session_state.letter_subtype = None + st.session_state.generated_letter = None + st.session_state.letter_metadata = {} + st.session_state.letter_input_data = {} + st.rerun() # Rerun to show the selection page + + # Main navigation logic + if st.session_state.letter_type is None: + # Display letter type selection if no type is selected + display_letter_type_selection() + else: + # Display the interface for the selected letter type + display_letter_interface(st.session_state.letter_type) + + # --- Placeholder for displaying generated letter and actions --- + # This part would typically be handled within the specific letter type modules + # after the letter is generated. However, if a common display is needed + # after returning from the module function, it would go here, but this + # requires the module function to somehow signal completion or store + # the generated letter in session state. The current structure expects + # the module's write_letter() to handle its entire lifecycle. + + # Example of potentially displaying a generated letter after returning + # (This assumes the module updates st.session_state.generated_letter) + # if st.session_state.generated_letter: + # st.subheader("Generated Letter Preview") + # st.text_area("Your Letter", st.session_state.generated_letter, height=400) + # # Add options like copy, download, analyze, edit, etc. + + +if __name__ == "__main__": + # Run the main letter writing function when the script is executed + write_letter() \ No newline at end of file diff --git a/lib/ai_writers/ai_letter_writer/personal_letter.py b/lib/ai_writers/ai_letter_writer/personal_letter.py new file mode 100644 index 00000000..c0d43f98 --- /dev/null +++ b/lib/ai_writers/ai_letter_writer/personal_letter.py @@ -0,0 +1,1121 @@ +""" +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.strip()}

" for p in content.split("\n\n") if p.strip()) + return f""" +
+
+ {metadata.get('date', 'Date')} +
+ +
+ {formatted_paragraphs if formatted_paragraphs else "

Letter content goes here...

"} +
+ +
+

{metadata.get('complimentary_close', 'Sincerely,')}

+

{metadata.get('sender_name', 'Sender Name')}

+
+
+ """ + +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(""" + + """, 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

{letter_type_config['description']}

", + 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"", 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() diff --git a/lib/ai_writers/ai_letter_writer/utils/letter_analyzer.py b/lib/ai_writers/ai_letter_writer/utils/letter_analyzer.py new file mode 100644 index 00000000..49854565 --- /dev/null +++ b/lib/ai_writers/ai_letter_writer/utils/letter_analyzer.py @@ -0,0 +1,493 @@ +""" +Letter Analyzer Utility + +This module provides functions for analyzing letter content, including tone, +formality, readability, and offering basic suggestions for improvement. +Note: The analysis methods provided here are simplified rule-based and +keyword-based approaches. For more sophisticated analysis in a production +environment, consider using advanced Natural Language Processing (NLP) +libraries and models. +""" + +import re +from typing import Dict, Any, Tuple, List + +def analyze_letter_tone(content: str) -> Dict[str, float]: + """ + Analyze the tone of a letter based on the presence of specific keywords + and phrases. + + Args: + content: The letter content to analyze. + + Returns: + Dictionary with tone scores (formal, friendly, assertive, etc.). + Scores are based on the frequency of matching patterns and capped at 1.0. + """ + # This is a simplified version using keyword matching. + # A more sophisticated approach would involve NLP libraries for sentiment and tone analysis. + + # Initialize tone scores + # Scores are arbitrary counts normalized in a simple way + tone_scores = { + "formal": 0.0, + "friendly": 0.0, + "assertive": 0.0, + "respectful": 0.0, + "urgent": 0.0, + "apologetic": 0.0 + } + + # Define patterns for different tones (case-insensitive) + formal_patterns = [ + r"\bI am writing to\b", + r"\bI would like to\b", + r"\bplease find\b", + r"\bregarding\b", + r"\bpursuant to\b", + r"\bhereby\b", + r"\bthus\b", + r"\btherefore\b", + r"\bfurthermore\b", + r"\bconsequently\b", + r"\bnevertheless\b", + r"\bmoreover\b", + r"\benclosed\b", # Added common formal word + r"\bherewith\b" # Added common formal word + ] + + friendly_patterns = [ + r"\bhope you're well\b", + r"\bhope this finds you well\b", + r"\bgreat to hear\b", + r"\blooking forward\b", + r"\bthanks\b", + r"\bappreciate\b", + r"!", # Exclamation points often indicate friendly or excited tone + r"\bexcited\b", + r"\bgreat\b", # Common friendly adjective + r"\bnice\b" # Common friendly adjective + ] + + assertive_patterns = [ + r"\brequire\b", + r"\bmust\b", + r"\bneed\b", + r"\bexpect\b", + r"\bdemand\b", + r"\binsist\b", + r"\bimmediately\b", + r"\baction\b", # Often used in assertive contexts + r"\bresolution\b" # Can imply assertion + ] + + respectful_patterns = [ + r"\brespectfully\b", + r"\bhonored\b", + r"\bplease\b", + r"\bkindly\b", + r"\bgrateful\b", + r"\bthank you\b", + r"\bappreciate\b", + r"\bhumbly\b", # Added respectful word + r"\bapologies\b" # Can show respect for impact + ] + + urgent_patterns = [ + r"\burgent\b", + r"\bas soon as possible\b", + r"\bASAP\b", + r"\bimmediately\b", + r"\bpressing\b", + r"\bcritical\b", + r"\bdeadline\b", + r"\bexpedite\b", # Added urgent word + r"\bpromptly\b" # Added urgent word + ] + + apologetic_patterns = [ + r"\bapologize\b", + r"\bsorry\b", + r"\bregret\b", + r"\bmistake\b", + r"\berror\b", + r"\binconvenience\b", + r"\bfault\b", # Added apologetic word + r"\boversight\b" # Added apologetic word + ] + + # Count pattern matches and update scores (arbitrary weighting) + # A simple count multiplied by a factor acts as a basic indicator + for pattern in formal_patterns: + tone_scores["formal"] += len(re.findall(pattern, content, re.IGNORECASE)) * 0.2 + + for pattern in friendly_patterns: + tone_scores["friendly"] += len(re.findall(pattern, content, re.IGNORECASE)) * 0.2 + + for pattern in assertive_patterns: + tone_scores["assertive"] += len(re.findall(pattern, content, re.IGNORECASE)) * 0.2 + + for pattern in respectful_patterns: + tone_scores["respectful"] += len(re.findall(pattern, content, re.IGNORECASE)) * 0.2 + + for pattern in urgent_patterns: + tone_scores["urgent"] += len(re.findall(pattern, content, re.IGNORECASE)) * 0.2 + + for pattern in apologetic_patterns: + tone_scores["apologetic"] += len(re.findall(pattern, content, re.IGNORECASE)) * 0.2 + + # Cap scores at 1.0 (arbitrary capping) + # A more meaningful score might be relative frequency or use a proper model + for tone in tone_scores: + tone_scores[tone] = min(tone_scores[tone], 1.0) + + return tone_scores + +def check_formality(content: str) -> float: + """ + Check the formality level of a letter based on the presence of formal + vs. informal indicators and contractions. + + Args: + content: The letter content to analyze. + + Returns: + Formality score between 0.0 (very informal) and 1.0 (very formal). + Calculated as formal_count / (formal_count + informal_count). + """ + # This is a simplified version based on keyword counting. + # More accurate formality analysis would require advanced NLP techniques. + + # Define formal and informal indicators (case-insensitive) + formal_indicators = [ + r"\bDear\b", + r"\bSincerely\b", + r"\bRegards\b", + r"\bRespectfully\b", + r"\bI am writing to\b", + r"\bI would like to\b", + r"\bplease find\b", + r"\bregarding\b", + r"\bpursuant to\b", + r"\bhereby\b", + r"\bthus\b", + r"\btherefore\b", + r"\bfurthermore\b", + r"\bconsequently\b", + r"\bnevertheless\b", + r"\bmoreover\b", + r"\benclosed\b", + r"\bherewith\b", + r"\bsincerely yours\b", # Added + r"\bto whom it may concern\b" # Added + ] + + informal_indicators = [ + r"\bHey\b", + r"\bHi\b", + r"\bWhat's up\b", + r"\bCheers\b", + r"\bThanks\b", # 'Thank you' is formal, 'Thanks' is informal + r"\bTake care\b", + r"\bSee you\b", + r"\bLater\b", + r"\bBye\b", + r"\bLove\b", # As a closing + r"\bXO\b", + r"!+", # Multiple exclamation points + r"\bawesome\b", + r"\bcool\b", + r"\bgreat\b", + r"\bnice\b", + r"\bbtw\b", # By the way + r"\bimo\b", # In my opinion + r"\blol\b" # Laugh out loud + ] + + # Define common contractions (case-insensitive) + contractions = [ + r"\bdon't\b", r"\bcan't\b", r"\bwon't\b", r"\bshouldn't\b", + r"\bcouldn't\b", r"\bwouldn't\b", r"\bhasn't\b", r"\bhaven't\b", + r"\bisn't\b", r"\baren't\b", r"\bwasn't\b", r"\bweren't\b", + r"\bi'm\b", r"\byou're\b", r"\bhe's\b", r"\bshe's\b", r"\bit's\b", + r"\bwe're\b", r"\bthey're\b", r"\bi've\b", r"\byou've\b", + r"\bwe've\b", r"\bthey've\b", r"\bi'd\b", r"\byou'd\b", + r"\bhe'd\b", r"\bshe'd\b", r"\bit'd\b", r"\bwe'd\b", r"\bthey'd\b", + r"\bi'll\b", r"\byou'll\b", r"\bhe'll\b", r"\bshe'll\b", r"\bit'll\b", + r"\bwe'll\b", r"\bthey'll\b" + ] + + formal_count = 0 + for pattern in formal_indicators: + formal_count += len(re.findall(pattern, content, re.IGNORECASE)) + + informal_count = 0 + for pattern in informal_indicators: + informal_count += len(re.findall(pattern, content, re.IGNORECASE)) + + # Count contractions as informal indicators + for pattern in contractions: + informal_count += len(re.findall(pattern, content, re.IGNORECASE)) + + # Calculate formality score + total_indicators = formal_count + informal_count + if total_indicators == 0: + # If no indicators found, return a neutral score + return 0.5 + + # Score is the proportion of formal indicators + formality_score = formal_count / total_indicators + return formality_score + +def count_syllables_simple(word: str) -> int: + """ + Counts syllables in a word using a simplified heuristic. + This method is not linguistically perfect but provides a basic estimate + for readability formulas. + + Args: + word: The word string. + + Returns: + Estimated syllable count. + """ + word = word.lower() + if len(word) <= 3: + # Assume short words have one syllable + return 1 + + # Remove common silent endings like 'e', 'es', 'ed' + if word.endswith(('es', 'ed')): + word = word[:-2] + elif word.endswith('e'): + word = word[:-1] + + # Count vowel groups (consecutive vowels count as one syllable) + vowels = 'aeiouy' + count = 0 + prev_is_vowel = False + + for char in word: + is_vowel = char in vowels + if is_vowel and not prev_is_vowel: + count += 1 + prev_is_vowel = is_vowel + + # Ensure at least one syllable is counted + return max(1, count) + + +def get_readability_metrics(content: str) -> Dict[str, Any]: + """ + Calculate readability metrics for a letter using simplified methods + like Flesch Reading Ease. + + Args: + content: The letter content to analyze. + + Returns: + Dictionary with readability metrics: word_count, sentence_count, + avg_words_per_sentence, flesch_reading_ease, reading_level. + """ + # Split content into words and sentences using simple regex + words = re.findall(r'\b\w+\b', content) + # Split by common sentence terminators, handling potential multiple marks + sentences = re.split(r'[.!?]+\s*', content) + # Filter out empty strings resulting from the split (e.g., trailing punctuation) + sentences = [s for s in sentences if s.strip()] + + word_count = len(words) + sentence_count = len(sentences) + syllable_count = sum(count_syllables_simple(word) for word in words) + + if word_count == 0 or sentence_count == 0: + return { + "word_count": word_count, + "sentence_count": sentence_count, + "avg_words_per_sentence": 0.0, + "flesch_reading_ease": 0.0, + "reading_level": "N/A" + } + + # Calculate average words per sentence + avg_words_per_sentence = word_count / sentence_count + + # Calculate Flesch Reading Ease Score + # Formula: 206.835 - (1.015 * AvgWordsPerSentence) - (84.6 * AvgSyllablesPerWord) + # AvgSyllablesPerWord = syllable_count / word_count + avg_syllables_per_word = syllable_count / word_count if word_count > 0 else 0 + + flesch = 206.835 - (1.015 * avg_words_per_sentence) - (84.6 * avg_syllables_per_word) + # Clamp score between 0 and 100 + flesch = max(0.0, min(100.0, flesch)) + + # Determine reading level based on Flesch score ranges + if flesch >= 90: + reading_level = "Very Easy (5th grade)" + elif flesch >= 80: + reading_level = "Easy (6th grade)" + elif flesch >= 70: + reading_level = "Fairly Easy (7th grade)" + elif flesch >= 60: + reading_level = "Standard (8th-9th grade)" + elif flesch >= 50: + reading_level = "Fairly Difficult (10th-12th grade)" + elif flesch >= 30: + reading_level = "Difficult (College)" + else: + reading_level = "Very Difficult (Graduate)" + + return { + "word_count": word_count, + "sentence_count": sentence_count, + "avg_words_per_sentence": round(avg_words_per_sentence, 2), # Rounded for display + "flesch_reading_ease": round(flesch, 2), # Rounded for display + "reading_level": reading_level + } + +def suggest_improvements(content: str, letter_type: str) -> List[str]: + """ + Suggest improvements for a letter based on its content, basic analysis, + and target letter type. + + Args: + content: The letter content to analyze. + letter_type: The type of letter (e.g., "business", "cover", "personal"). + + Returns: + List of improvement suggestions strings. + """ + suggestions = [] + + words = re.findall(r'\b\w+\b', content) + word_count = len(words) + + # Basic length check based on letter type + if letter_type in ["business", "formal"]: + if word_count < 100 and word_count > 10: # Avoid suggesting for very short placeholders + suggestions.append("Consider adding more details to make your letter more comprehensive.") + elif word_count > 600: # Increased max length slightly + suggestions.append("Your letter is quite long. Consider condensing it for better readability and focus.") + elif letter_type == "cover": + if word_count < 150 and word_count > 10: # Avoid suggesting for very short placeholders + suggestions.append("Your cover letter may be too brief. Consider highlighting more of your relevant qualifications.") + elif word_count > 500: # Increased max length slightly + suggestions.append("Your cover letter is quite long. Consider focusing on your most relevant qualifications and experiences.") + elif letter_type == "recommendation": + if word_count < 150 and word_count > 10: + suggestions.append("Consider adding more specific examples or anecdotes to strengthen the recommendation.") + elif word_count > 600: + suggestions.append("Your recommendation letter is quite long. Ensure it remains focused and impactful.") + + + # Check for overuse of "I" (simple count-based heuristic) + # Count "I" as a standalone word + i_count = len(re.findall(r"\bI\b", content)) + # Avoid suggestion for very short content or content with few sentences + sentence_count = len(re.split(r'[.!?]+\s*', content.strip())) + if sentence_count > 2 and word_count > 50 and i_count > sentence_count * 1.5: # Suggest if 'I' count is significantly higher than sentence count + suggestions.append("Your letter contains many uses of 'I'. Consider rephrasing some sentences to focus more on the recipient or the subject matter.") + + + # Check for expression of gratitude (using common phrases) + gratitude_patterns = [r"\bthank you\b", r"\bgrateful\b", r"\bappreciate\b"] + has_gratitude = any(re.search(pattern, content, re.IGNORECASE) for pattern in gratitude_patterns) + # Suggest adding gratitude, but avoid for letter types where it might be less common (e.g., some complaint letters) + if not has_gratitude and letter_type not in ["complaint", "urgent"]: + suggestions.append("Consider expressing gratitude or appreciation somewhere in your letter.") + + # Check for clear call to action (using common phrases) + # Phrases indicating desired action or next step + action_phrases = [ + "look forward to", "please", "would appreciate", "request", + "hope to", "call me", "email me", "contact me", "schedule", + "arrange", "require action", "next steps" + ] + has_call_to_action = any(phrase in content.lower() for phrase in action_phrases) + # Suggest adding a call to action for relevant letter types + if not has_call_to_action and letter_type in ["business", "cover", "complaint", "invitation"]: + suggestions.append("Consider adding a clear call to action or outlining the desired next steps.") + + # Check for proper closing (using common phrases) + closing_patterns = [ + r"\bSincerely\b", r"\bRegards\b", r"\bThank you\b", r"\bBest regards\b", + r"\bYours sincerely\b", r"\bYours faithfully\b", r"\bRespectfully\b", + r"\bBest wishes\b", r"\bKind regards\b" + ] + # Check if any standard closing phrase is present, typically near the end + # A more robust check might look specifically at the last paragraph/lines + has_proper_closing = any(re.search(pattern, content[-200:], re.IGNORECASE) for pattern in closing_patterns) # Check last 200 chars + + if not has_proper_closing and word_count > 20: # Avoid suggesting for very short snippets + suggestions.append("Consider adding a proper closing phrase (e.g., Sincerely, Regards) followed by your name.") + + return suggestions + +# Example usage (for testing purposes, not part of the module's core functionality) +if __name__ == '__main__': + sample_formal_letter = """ + Dear Mr. Smith, + + I am writing to follow up regarding the project proposal submitted on October 26, 2023. + We believe the proposed solution aligns well with your stated requirements. + Please find the revised budget document attached for your review. + We look forward to your feedback at your earliest convenience. + + Sincerely, + Jane Doe + """ + + sample_informal_letter = """ + Hey John, + + Hope you're doing well! Just wanted to quickly touch base about the party next week. + Excited to catch up with everyone! Let me know if you need any help setting up. + Thanks! + + Best, + Alex + """ + + sample_complaint_letter = """ + To Whom It May Concern, + + I am writing to complain about the faulty product I received on November 1, 2023 (Order #12345). + The device stopped working after only two days of use. I require a full refund or replacement immediately. + I expect a prompt response regarding this issue. + + Sincerely, + Concerned Customer + """ + + print("--- Analyzing Formal Letter ---") + tone = analyze_letter_tone(sample_formal_letter) + formality = check_formality(sample_formal_letter) + readability = get_readability_metrics(sample_formal_letter) + suggestions = suggest_improvements(sample_formal_letter, "business") + + print(f"Tone: {tone}") + print(f"Formality: {formality:.2f}") + print(f"Readability: {readability}") + print(f"Suggestions: {suggestions}") + + print("\n--- Analyzing Informal Letter ---") + tone = analyze_letter_tone(sample_informal_letter) + formality = check_formality(sample_informal_letter) + readability = get_readability_metrics(sample_informal_letter) + suggestions = suggest_improvements(sample_informal_letter, "personal") + + print(f"Tone: {tone}") + print(f"Formality: {formality:.2f}") + print(f"Readability: {readability}") + print(f"Suggestions: {suggestions}") + + print("\n--- Analyzing Complaint Letter ---") + tone = analyze_letter_tone(sample_complaint_letter) + formality = check_formality(sample_complaint_letter) + readability = get_readability_metrics(sample_complaint_letter) + suggestions = suggest_improvements(sample_complaint_letter, "complaint") + + print(f"Tone: {tone}") + print(f"Formality: {formality:.2f}") + print(f"Readability: {readability}") + print(f"Suggestions: {suggestions}") \ No newline at end of file diff --git a/lib/ai_writers/ai_letter_writer/utils/letter_formatter.py b/lib/ai_writers/ai_letter_writer/utils/letter_formatter.py new file mode 100644 index 00000000..97a0aadc --- /dev/null +++ b/lib/ai_writers/ai_letter_writer/utils/letter_formatter.py @@ -0,0 +1,545 @@ +""" +Letter Formatter Module + +This module provides utilities for formatting letters and generating HTML +previews in different styles (Personal, Formal, Business, Cover). +The formatting functions here are primarily focused on generating HTML +for preview purposes, applying standard layout conventions for each letter type +using inline CSS styles. +""" + +import re +from typing import Dict, Any + +def format_letter(content: str, metadata: Dict[str, Any], letter_type: str = "personal") -> str: + """ + Format a letter with basic structure (paragraphs). + + Args: + content: The raw letter content (string). + metadata: Dictionary containing metadata (currently not used for formatting in this placeholder). + letter_type: Type of letter (personal, formal, business, cover). + + Returns: + Formatted letter content (currently just returns the input content). + This is a placeholder and would be expanded to apply specific + formatting rules (e.g., indentation, spacing) based on letter type + and metadata in a full implementation before generating HTML. + For this module, we primarily rely on the HTML generation functions + to handle the visual formatting. + """ + # This is a basic placeholder. In a real implementation, this function + # might process the raw text content to add indentation, adjust line breaks, + # or handle specific markdown-like syntax before it's passed to the + # HTML generation functions. + # For now, we assume the input `content` uses double newlines for paragraphs. + return content + +def get_letter_preview_html(content: str, metadata: Dict[str, Any], letter_type: str = "personal") -> str: + """ + Generate HTML for letter preview based on letter type and metadata. + This function acts as a dispatcher to the specific HTML generation functions. + + Args: + content: The letter content string. + metadata: Dictionary containing metadata like sender/recipient info, date, etc. + letter_type: Type of letter ("personal", "formal", "business", "cover"). + Defaults to "personal". + + Returns: + HTML string for letter preview, styled appropriately for the type. + Includes basic styling for a printable letter appearance. + """ + # Dispatch to the appropriate HTML generation function based on letter type + # Pass the content and metadata to the specific functions + if letter_type == "personal": + return get_personal_letter_html(content, metadata) + elif letter_type == "formal": + return get_formal_letter_html(content, metadata) + elif letter_type == "business": + return get_business_letter_html(content, metadata) + elif letter_type == "cover": + return get_cover_letter_html(content, metadata) + else: + # Fallback for unrecognized types, displaying raw content in a styled box + return f""" +
+

Preview Unavailable for Unknown Letter Type

+

The letter type '{letter_type}' is not recognized. Displaying raw content:

+
{content}
+
+ """ + +def get_personal_letter_html(content: str, metadata: Dict[str, Any]) -> str: + """ + Generate HTML for personal letter preview with basic styling. + Uses a more informal layout and font style. + + Args: + content: The letter content string. + metadata: Dictionary containing personal letter metadata (sender_name, date). + + Returns: + HTML string for personal letter preview. + """ + # Extract metadata with default empty strings for robustness + sender_name = metadata.get("sender_name", "") + # recipient_name = metadata.get("recipient_name", "") # Less common in personal body, but could be used in greeting + date = metadata.get("date", "") + + # Split content into paragraphs based on double newlines + # Use list comprehension to strip whitespace and filter out empty strings + paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()] + + # Format paragraphs as HTML

tags with bottom margin + formatted_paragraphs = "".join(f"

{paragraph}

" for paragraph in paragraphs) + + # Basic HTML structure with inline styles for a personal letter feel + # Styles aim for a warm, readable appearance + html = f""" +
+
+ {date if date else "[Date]"} +
+ +
+ {formatted_paragraphs if formatted_paragraphs else "

Letter content goes here...

"} +
+ +
+

Sincerely,

+

{sender_name if sender_name else "[Sender Name]"}

+
+
+ """ + return html + +def get_formal_letter_html(content: str, metadata: Dict[str, Any]) -> str: + """ + Generate HTML for formal letter preview with standard formal structure and styling. + Uses a more professional layout and font style (Arial/sans-serif). + + Args: + content: The letter content string. + metadata: Dictionary containing formal letter metadata. + + Returns: + HTML string for formal letter preview. + """ + # Extract metadata with default empty strings + sender_name = metadata.get("sender_name", "") + sender_title = metadata.get("sender_title", "") + sender_organization = metadata.get("sender_organization", "") + # Replace newlines in address for HTML display + sender_address = metadata.get("sender_address", "").replace("\n", "
") + sender_phone = metadata.get("sender_phone", "") + sender_email = metadata.get("sender_email", "") + + recipient_name = metadata.get("recipient_name", "") + recipient_title = metadata.get("recipient_title", "") + recipient_organization = metadata.get("recipient_organization", "") + # Replace newlines in address for HTML display + recipient_address = metadata.get("recipient_address", "").replace("\n", "
") + + date = metadata.get("date", "") + subject = metadata.get("subject", "") # Added subject line + salutation = metadata.get("salutation", "Dear Sir/Madam,") # Added salutation + complimentary_close = metadata.get("complimentary_close", "Sincerely,") # Added close + + # Determine alignment based on letter format (simplified) + # Full Block: All aligned left + # Modified Block: Sender address block, date, closing, and signature are right-aligned + letter_format = metadata.get("letter_format", "Full Block") + sender_address_align = "left" + date_align = "left" + closing_align = "left" + + if letter_format == "Modified Block": + sender_address_align = "right" + date_align = "right" + closing_align = "right" + + # Split content into paragraphs based on double newlines + paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()] + + # Format paragraphs as HTML

tags with bottom margin + formatted_paragraphs = "".join(f"

{paragraph}

" for paragraph in paragraphs) + + # Basic HTML structure with inline styles for a formal letter + html = f""" +
+ +
+

{sender_name if sender_name else "[Sender Name]"}{', ' + sender_title if sender_title else ''}

+

{sender_organization if sender_organization else "[Sender Organization]"}

+

{sender_address if sender_address else "[Sender Address]"}

+

{sender_phone}

+

{sender_email}

+
+ +
+

{date if date else "[Date]"}

+
+ +
+

{recipient_name if recipient_name else "[Recipient Name]"}{', ' + recipient_title if recipient_title else ''}

+

{recipient_organization if recipient_organization else "[Recipient Organization]"}

+

{recipient_address if recipient_address else "[Recipient Address]"}

+
+ +
+

Subject: {subject if subject else "[Subject Line]"}

+
+ +
+

{salutation}

+
+ +
+ {formatted_paragraphs if formatted_paragraphs else "

Letter content goes here...

"} +
+ +
+

{complimentary_close}

+

{sender_name}

+

{sender_title}

+

{sender_organization}

+
+
+ """ + return html + +def get_business_letter_html(content: str, metadata: Dict[str, Any]) -> str: + """ + Generate HTML for business letter preview with standard business structure and styling. + Includes optional letterhead. + + Args: + content: The letter content string. + metadata: Dictionary containing business letter metadata. + + Returns: + HTML string for business letter preview. + """ + # Extract metadata with default empty strings + sender_company = metadata.get("sender_company", "") + sender_name = metadata.get("sender_name", "") + sender_title = metadata.get("sender_title", "") + sender_address = metadata.get("sender_address", "").replace("\n", "
") + sender_phone = metadata.get("sender_phone", "") + sender_email = metadata.get("sender_email", "") + sender_website = metadata.get("sender_website", "") + + recipient_company = metadata.get("recipient_company", "") + recipient_name = metadata.get("recipient_name", "") + recipient_title = metadata.get("recipient_title", "") + recipient_address = metadata.get("recipient_address", "").replace("\n", "
") + + date = metadata.get("date", "") + subject = metadata.get("subject", "") # Added subject line + salutation = metadata.get("salutation", "Dear Sir/Madam,") # Added salutation + complimentary_close = metadata.get("complimentary_close", "Sincerely,") # Added close + + # Determine alignment based on letter format (simplified) + letter_format = metadata.get("letter_format", "Full Block") + sender_info_align = "left" + date_align = "left" + closing_align = "left" + + if letter_format == "Modified Block": + sender_info_align = "right" + date_align = "right" + closing_align = "right" + + # Include letterhead logic + include_letterhead = metadata.get("include_letterhead", True) + + # Split content into paragraphs based on double newlines + paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()] + + # Format paragraphs as HTML

tags with bottom margin + formatted_paragraphs = "".join(f"

{paragraph}

" for paragraph in paragraphs) + + # Create letterhead HTML if included and company name is provided + letterhead_html = "" + if include_letterhead and sender_company: + letterhead_html = f""" +
+

{sender_company}

+

+ {sender_address.replace('
', ', ') if sender_address else ''} + {' | ' + sender_phone if sender_phone else ''} + {' | ' + sender_email if sender_email else ''} + {' | ' + sender_website if sender_website else ''} +

+
+ """ + + # Basic HTML structure with inline styles for a business letter + html = f""" +
+ {letterhead_html} + +
+

{date if date else "[Date]"}

+
+ +
+

{recipient_name if recipient_name else "[Recipient Name]"}{', ' + recipient_title if recipient_title else ''}

+

{recipient_company if recipient_company else "[Recipient Company]"}

+

{recipient_address if recipient_address else "[Recipient Address]"}

+
+ +
+

Subject: {subject if subject else "[Subject Line]"}

+
+ +
+

{salutation}

+
+ +
+ {formatted_paragraphs if formatted_paragraphs else "

Letter content goes here...

"} +
+ +
+

{complimentary_close}

+

{sender_name if sender_name else "[Sender Name]"}

+

{sender_title}

+

{sender_company}

+
+
+ """ + return html + +def get_cover_letter_html(content: str, metadata: Dict[str, Any]) -> str: + """ + Generate HTML for cover letter preview with standard cover letter structure and styling. + Includes sender contact block and optional online links. + + Args: + content: The letter content string. + metadata: Dictionary containing cover letter metadata. + + Returns: + HTML string for cover letter preview. + """ + # Extract metadata with default empty strings + sender_name = metadata.get("sender_name", "") + sender_email = metadata.get("sender_email", "") + sender_phone = metadata.get("sender_phone", "") + sender_location = metadata.get("sender_location", "") + sender_linkedin = metadata.get("sender_linkedin", "") + sender_portfolio = metadata.get("sender_portfolio", "") + + recipient_name = metadata.get("recipient_name", "") + recipient_title = metadata.get("recipient_title", "") # Added recipient title + recipient_company = metadata.get("recipient_company", "") + recipient_department = metadata.get("recipient_department", "") # Added department + recipient_address = metadata.get("recipient_address", "").replace("\n", "
") # Added recipient address + + date = metadata.get("date", "") + job_title = metadata.get("job_title", "") # Added job title for subject + salutation = metadata.get("salutation", "Dear Hiring Manager,") # Added salutation + complimentary_close = metadata.get("complimentary_close", "Sincerely,") # Added close + + + # Split content into paragraphs based on double newlines + paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()] + + # Format paragraphs as HTML

tags with bottom margin + formatted_paragraphs = "".join(f"

{paragraph}

" for paragraph in paragraphs) + + # Construct sender contact line, only including fields that have values + sender_contact_parts = [sender_location, sender_phone, sender_email] + sender_contact_line = " | ".join(filter(None, sender_contact_parts)) + + # Construct sender online links line, only including fields that have values + sender_online_parts = [] + if sender_linkedin: + # Add basic styling for links + sender_online_parts.append(f'LinkedIn') + if sender_portfolio: + # Add basic styling for links + sender_online_parts.append(f'Portfolio') + + sender_online_line = " | ".join(filter(None, sender_online_parts)) + + + # Basic HTML structure with inline styles for a cover letter + # Styles aim for a clean, professional look + html = f""" +
+ +
+

{sender_name if sender_name else "[Your Name]"}

+ {'

' + sender_contact_line + '

' if sender_contact_line else ''} + {'

' + sender_online_line + '

' if sender_online_line else ''} +
+ +
+

{date if date else "[Date]"}

+
+ +
+

{recipient_name if recipient_name else "[Recipient Name]"}{', ' + recipient_title if recipient_title else ''}

+

{recipient_department}

+

{recipient_company if recipient_company else "[Recipient Company]"}

+

{recipient_address if recipient_address else "[Recipient Address]"}

+
+ +
+

Subject: Application for {job_title if job_title else '[Job Title]'} Position

+
+ +
+

{salutation}

+
+ +
+ {formatted_paragraphs if formatted_paragraphs else "

Letter content goes here...

"} +
+ +
+

{complimentary_close}

+

{sender_name}

+
+
+ """ + return html + +# Example usage (for testing purposes) +if __name__ == '__main__': + sample_personal_content = """ + Hi Sarah, + + Hope you're doing well! + + Just wanted to send a quick note to say how much I enjoyed catching up last week. It was great hearing about your trip to Italy. + + Let's try to do it again soon! + + Best, + Emily + """ + sample_personal_metadata = { + "sender_name": "Emily Davis", + "recipient_name": "Sarah Johnson", + "date": "November 5, 2023" + } + + sample_formal_content = """ + I am writing to formally request a copy of my academic transcript. + + I require this document for a graduate school application. The deadline for submission is December 15, 2023. + + Please let me know if there are any fees associated with this request or if any further information is needed from my end. + + Thank you for your time and assistance. + """ + sample_formal_metadata_full_block = { + "sender_name": "John Smith", + "sender_title": "Student", + "sender_organization": "University of Example", + "sender_address": "123 University Ave\nAnytown, CA 91234", + "sender_phone": "(555) 123-4567", + "sender_email": "john.smith@example.com", + "recipient_name": "Registrar's Office", + "recipient_organization": "University of Example", + "recipient_address": "456 Admin Building\nAnytown, CA 91234", + "date": "November 5, 2023", + "subject": "Request for Academic Transcript", + "salutation": "To the Registrar's Office,", + "complimentary_close": "Sincerely,", + "letter_format": "Full Block" + } + + sample_formal_metadata_modified_block = sample_formal_metadata_full_block.copy() + sample_formal_metadata_modified_block["letter_format"] = "Modified Block" + + + sample_business_content = """ + This letter confirms the details of Purchase Order #PO-7890. + + We are ordering 50 units of Model X widgets at the agreed-upon price of $100 per unit, totaling $5,000. + + Please ensure delivery to our warehouse by November 20, 2023. Payment will be made within 30 days of receipt of invoice. + + Thank you for your prompt processing of this order. + """ + sample_business_metadata_full_block = { + "sender_company": "Acme Corp", + "sender_name": "Alice Brown", + "sender_title": "Procurement Manager", + "sender_address": "789 Business Rd\nMetropolis, NY 10001", + "sender_phone": "(555) 987-6543", + "sender_email": "alice.brown@acmecorp.com", + "sender_website": "www.acmecorp.com", + "recipient_company": "Supplier Co.", + "recipient_name": "Sales Department", + "recipient_title": "", + "recipient_address": "101 Vendor Lane\nIndustriatown, TX 75001", + "date": "November 5, 2023", + "subject": "Purchase Order Confirmation - PO-7890", + "salutation": "To the Sales Department,", + "complimentary_close": "Sincerely,", + "letter_format": "Full Block", + "include_letterhead": True + } + + sample_business_metadata_modified_block = sample_business_metadata_full_block.copy() + sample_business_metadata_modified_block["letter_format"] = "Modified Block" + sample_business_metadata_no_letterhead = sample_business_metadata_full_block.copy() + sample_business_metadata_no_letterhead["include_letterhead"] = False + + + sample_cover_letter_content = """ + I am writing to express my enthusiastic interest in the Marketing Specialist position advertised on LinkedIn. + + With three years of experience in digital marketing and a proven track record in content creation and social media management, I am confident in my ability to contribute to your team. My skills in [Specific Skill 1] and [Specific Skill 2] align perfectly with the requirements outlined in the job description. + + In my previous role at [Previous Company], I successfully managed social media campaigns that resulted in a 25% increase in engagement. I am particularly drawn to [Company Name]'s innovative approach to [Industry Trend] and believe my creative problem-solving skills would be a valuable asset. + + Thank you for considering my application. I have attached my resume for your review and welcome the opportunity to discuss how my background and skills can benefit [Company Name]. + """ + sample_cover_letter_metadata = { + "sender_name": "Jane Doe", + "sender_email": "jane.doe@email.com", + "sender_phone": "(123) 456-7890", + "sender_location": "San Francisco, CA", + "sender_linkedin": "https://linkedin.com/in/janedoe", + "sender_portfolio": "https://janedoeportfolio.com", + "recipient_name": "Hiring Manager", + "recipient_title": "", # Example with no recipient title + "recipient_company": "Innovative Solutions Inc.", + "recipient_department": "Marketing Department", + "recipient_address": "456 Tech Way\nSilicon Valley, CA 95001", + "date": "November 5, 2023", + "job_title": "Marketing Specialist", + "salutation": "Dear Hiring Manager,", + "complimentary_close": "Sincerely," + } + + print("--- Personal Letter HTML Preview ---") + print(get_letter_preview_html(sample_personal_content, sample_personal_metadata, letter_type="personal")) + + print("\n--- Formal Letter HTML Preview (Full Block) ---") + print(get_letter_preview_html(sample_formal_content, sample_formal_metadata_full_block, letter_type="formal")) + + print("\n--- Formal Letter HTML Preview (Modified Block) ---") + print(get_letter_preview_html(sample_formal_content, sample_formal_metadata_modified_block, letter_type="formal")) + + print("\n--- Business Letter HTML Preview (Full Block, with Letterhead) ---") + print(get_letter_preview_html(sample_business_content, sample_business_metadata_full_block, letter_type="business")) + + print("\n--- Business Letter HTML Preview (Modified Block, with Letterhead) ---") + print(get_letter_preview_html(sample_business_content, sample_business_metadata_modified_block, letter_type="business")) + + print("\n--- Business Letter HTML Preview (Full Block, no Letterhead) ---") + print(get_letter_preview_html(sample_business_content, sample_business_metadata_no_letterhead, letter_type="business")) + + print("\n--- Cover Letter HTML Preview ---") + print(get_letter_preview_html(sample_cover_letter_content, sample_cover_letter_metadata, letter_type="cover")) + + print("\n--- Unknown Type HTML Preview ---") + print(get_letter_preview_html("Some random content.", {}, letter_type="unknown")) diff --git a/lib/ai_writers/ai_letter_writer/utils/letter_templates.py b/lib/ai_writers/ai_letter_writer/utils/letter_templates.py new file mode 100644 index 00000000..6ae54466 --- /dev/null +++ b/lib/ai_writers/ai_letter_writer/utils/letter_templates.py @@ -0,0 +1,988 @@ +""" +Letter Templates Module + +This module provides structured templates and guidance for generating +different types and subtypes of letters. +Templates are defined as dictionaries containing a 'structure' (list of sections) +and 'guidance' (a string). +""" + +from typing import Dict, Any, List + +# Define letter templates using a nested dictionary structure for easier management +TEMPLATES: Dict[str, Dict[str, Dict[str, Any]]] = { + "personal": { + "congratulations": { + "structure": [ + "Greeting", + "Express congratulations", + "Acknowledge the achievement", + "Share personal thoughts/memory (optional)", + "Look to the future/well wishes", + "Closing" + ], + "guidance": "Be warm, sincere, and specific about the achievement. Express genuine happiness for the recipient. Keep the tone personal and friendly." + }, + "thank_you": { + "structure": [ + "Greeting", + "Express gratitude clearly", + "Specify what you are thankful for", + "Explain the impact or how you used it (optional)", + "Share a personal thought or memory (optional)", + "Offer reciprocation or look to the future", + "Closing" + ], + "guidance": "Be specific about what you're thankful for and how it affected you. Express sincere appreciation. Personalize the message." + }, + "sympathy": { + "structure": [ + "Greeting", + "Express sympathy for the loss", + "Acknowledge the significance of the person/situation", + "Share a positive memory or quality (optional)", + "Offer specific support (optional)", + "Closing with comforting words" + ], + "guidance": "Be gentle, compassionate, and sincere. Avoid clichés. Focus on offering genuine comfort and acknowledging the recipient's feelings." + }, + "apology": { + "structure": [ + "Greeting", + "Clearly state your apology", + "Acknowledge the specific mistake or action", + "Express understanding of the impact on the other person", + "Explain (briefly, without making excuses) what happened (optional)", + "Offer amends or suggest how to make things right", + "Assure it won't happen again", + "Closing" + ], + "guidance": "Be sincere, take full responsibility for your actions, and focus on making things right. Avoid making excuses or blaming others." + }, + "invitation": { + "structure": [ + "Greeting", + "Clearly state the invitation", + "Provide full event details (What, When, Where)", + "Explain the significance or purpose (optional)", + "Mention who else might be there (optional)", + "Request RSVP (date and contact method)", + "Express anticipation", + "Closing" + ], + "guidance": "Be clear and specific about the details (what, when, where, why). Make it easy for the person to respond." + }, + "friendship": { + "structure": [ + "Greeting", + "Express appreciation for the friendship", + "Share a recent memory or anecdote", + "Acknowledge the value of the relationship", + "Check in on them or share updates", + "Look to the future (getting together, etc.)", + "Closing" + ], + "guidance": "Be warm, personal, and specific about what you value in the friendship. Share updates and show genuine interest." + }, + "love": { + "structure": [ + "Greeting (Terms of endearment)", + "Express depth of feelings", + "Share a cherished memory or moment", + "Describe specific qualities you love and appreciate", + "Reaffirm commitment or future hopes", + "Closing (Terms of endearment)" + ], + "guidance": "Be sincere, personal, and specific about your feelings. Use sensory details and emotional language appropriate for your relationship." + }, + "encouragement": { + "structure": [ + "Greeting", + "Acknowledge the situation or challenge they face", + "Express belief in their abilities/strength", + "Offer specific words of encouragement or support", + "Remind them of past successes (optional)", + "Offer practical help (optional)", + "Look to the future with hope", + "Closing with support" + ], + "guidance": "Be positive, supportive, and specific about the person's strengths and abilities. Offer genuine encouragement and belief in them." + }, + "farewell": { + "structure": [ + "Greeting", + "State the purpose (saying goodbye)", + "Express feelings about their departure (sadness, happiness for them)", + "Share a positive memory or highlight their contribution", + "Express good wishes for their future endeavors", + "Look to staying in touch (optional)", + "Closing" + ], + "guidance": "Be warm, reflective, and forward-looking. Focus on positive memories and express genuine good wishes for their next steps." + }, + # Default personal letter template if subtype is not found + "default": { + "structure": [ + "Greeting", + "Introduction", + "Main content paragraphs", + "Closing thoughts", + "Signature" + ], + "guidance": "Be personal, authentic, and appropriate for your relationship with the recipient. The tone is typically informal to semi-formal." + } + }, + "formal": { + "application": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information (if known)", + "Subject line (Clear and concise)", + "Salutation (Formal)", + "Introduction (State position applied for and where you saw it)", + "Body paragraphs (Highlight relevant skills and experience)", + "Closing paragraph (Reiterate interest, mention enclosed resume, call to action)", + "Complimentary close (Formal)", + "Signature (Typed name)", + "Enclosures (Mention if attaching resume/portfolio)" + ], + "guidance": "Be professional, concise, and specific about your qualifications and genuine interest in the position. Tailor it to the specific job description." + }, + "complaint": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Clearly state it's a complaint)", + "Salutation (Formal)", + "Introduction (State the purpose: complaint about X service/product)", + "Problem description (Provide specific details: date, time, location, product details, names if applicable)", + "Impact statement (Explain how the problem affected you)", + "Requested resolution (Clearly state what you want: refund, replacement, action)", + "Closing paragraph (Reference attached documents, state expectation for response)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be clear, factual, and specific about the issue and your desired resolution. Maintain a respectful but firm tone. Include all relevant details." + }, + "request": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Clearly state the request)", + "Salutation (Formal)", + "Introduction (State the purpose: making a request)", + "Request details (Clearly explain what you are requesting)", + "Justification (Explain why the request is necessary or beneficial)", + "Provide supporting information (optional)", + "Closing paragraph (Express gratitude for consideration, reiterate call to action)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be clear, specific, and courteous about your request. Explain why it's important or beneficial to the recipient or organization." + }, + "recommendation": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Letter of Recommendation for [Name])", + "Salutation (Formal)", + "Introduction (State your name, title, relationship to the recommendee, and for what purpose the letter is written)", + "Body paragraphs (Describe the recommendee's qualifications, skills, and achievements with specific examples)", + "Highlight relevant experiences and contributions", + "Closing recommendation (Summarize endorsement, strongly recommend the person)", + "Complimentary close (Formal)", + "Signature (Typed name and title)" + ], + "guidance": "Be specific, positive, and credible. Use concrete examples and anecdotes to support your recommendation. Tailor it to the specific role/opportunity." + }, + "resignation": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information (Immediate supervisor/HR)", + "Subject line (Letter of Resignation - [Your Name])", + "Salutation (Formal)", + "Statement of resignation (Clearly state you are resigning)", + "Last day of employment (Specify the date)", + "Gratitude and reflection (Optional: Express thanks for the opportunity/experience)", + "Transition plan/Offer of assistance (Optional: Suggest how to ensure a smooth handover)", + "Closing paragraph (Express good wishes for the company's future)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be professional, positive (if possible), and clear about your departure and last day. Maintain a good relationship." + }, + "inquiry": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Clearly state the nature of the inquiry)", + "Salutation (Formal)", + "Introduction (State your purpose for writing - making an inquiry)", + "Inquiry details (Provide necessary context or background)", + "Specific questions (List your questions clearly, perhaps numbered)", + "Closing paragraph (Express gratitude for assistance, indicate when you need a response)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be clear, specific, and courteous about your inquiry. Organize your questions logically for easy answering." + }, + "authorization": { + "structure": [ + "Sender's contact information (The grantor of authority)", + "Date", + "Recipient's contact information (The person/entity receiving the letter)", + "Subject line (Letter of Authorization)", + "Salutation (Formal)", + "Statement of authorization (Clearly state who is authorized)", + "Authorized person's details (Full name, ID if applicable)", + "Scope of authority (Precisely define what they are authorized to do)", + "Limitations (Specify any restrictions or conditions)", + "Duration of authorization (Start and end dates, if applicable)", + "Closing paragraph (State responsibility, express confidence)", + "Complimentary close (Formal)", + "Signature (Typed name and title)" + ], + "guidance": "Be clear, specific, and precise about who is authorized, what they can do, for how long, and under what conditions. This is a legal document." + }, + "appeal": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information (Appeals committee/relevant authority)", + "Subject line (Letter of Appeal - [Your Name] - [Subject of Appeal])", + "Salutation (Formal)", + "Introduction (State your name, the decision being appealed, and the date of the decision)", + "Grounds for appeal (Clearly state the reasons why you believe the decision is incorrect)", + "Provide supporting evidence (Reference attached documents: records, photos, etc.)", + "Explain mitigating circumstances (Optional)", + "Requested outcome (Clearly state what resolution you seek)", + "Closing paragraph (Express hope for reconsideration, gratitude for time)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be respectful, factual, and persuasive. Focus on valid grounds for appeal and provide clear, supporting evidence. Maintain a formal tone." + }, + "introduction": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Introduction - [Your Name])", + "Salutation (Formal)", + "Introduction (Introduce yourself and the purpose of the letter)", + "Background information (Briefly describe your relevant background or expertise)", + "Reason for reaching out (Explain why you are introducing yourself to this specific person/entity)", + "Potential areas of collaboration or shared interest (Optional)", + "Call to action (Suggest a meeting, call, or further communication)", + "Closing paragraph (Express enthusiasm for potential connection)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be professional, informative, and engaging. Clearly explain who you are, your expertise, and why you're reaching out to them specifically." + }, + # Default formal letter template if subtype is not found + "default": { + "structure": [ + "Sender's address", + "Date", + "Recipient's address", + "Subject line", + "Salutation", + "Introduction", + "Body paragraphs", + "Closing paragraph", + "Complimentary close", + "Signature" + ], + "guidance": "Be professional, clear, and concise. Use formal language and structure. The tone is typically formal." + } + }, + "business": { + "sales": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line (Benefit-oriented)", + "Salutation", + "Attention-grabbing opening (Address a pain point or introduce a benefit)", + "Problem statement (Briefly describe the challenge the recipient faces)", + "Solution presentation (Introduce your product/service as the solution)", + "Benefits and features (Explain how your solution helps, focusing on benefits)", + "Social proof (Optional: Testimonials, case studies, data)", + "Call to action (Clearly state what you want them to do next)", + "Closing paragraph (Reiterate benefit, create urgency/incentive)", + "Complimentary close (Professional)", + "Signature (Typed name and title)", + "Enclosures (Optional: Brochure, pricing)" + ], + "guidance": "Be persuasive, customer-focused, and clear about the value proposition. Focus on benefits, not just features. Make the call to action obvious." + }, + "proposal": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line (Clear and descriptive)", + "Salutation", + "Introduction (State purpose: submitting a proposal)", + "Problem statement/Needs assessment (Demonstrate understanding of client's needs)", + "Proposed solution (Describe your solution in detail)", + "Implementation plan (Outline steps and timeline)", + "Costs and investment (Clearly state pricing and payment terms)", + "Benefits and ROI (Explain the value the client will receive)", + "Call to action (Suggest next steps: meeting, discussion)", + "Closing paragraph (Express enthusiasm, availability for questions)", + "Complimentary close (Professional)", + "Signature (Typed name and title)", + "Enclosures (Proposal document, appendix)" + ], + "guidance": "Be clear, specific, and persuasive about your solution. Focus on the client's needs and the value you provide. Structure it logically." + }, + "order": { + "structure": [ + "Letterhead (Your company)", + "Date", + "Recipient's address (Supplier)", + "Subject line (Purchase Order - [PO Number])", + "Salutation", + "Introduction (Reference quote/agreement, state purpose: placing an order)", + "Order details (Item list with quantities, descriptions, unit prices, total)", + "Delivery requirements (Shipping address, requested delivery date, shipping method)", + "Payment terms (Reference agreed terms)", + "Closing paragraph (Express expectation for timely delivery)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be clear, specific, and detailed about what you're ordering, quantities, delivery requirements, and payment terms. Include a purchase order number." + }, + "quotation": { + "structure": [ + "Letterhead (Your company)", + "Date", + "Recipient's address (Customer)", + "Subject line (Quotation for [Product/Service])", + "Salutation", + "Introduction (Reference inquiry, state purpose: providing a quotation)", + "Quotation details (List items/services, descriptions, unit prices, quantities, line totals)", + "Pricing breakdown (Mention taxes, discounts, fees separately)", + "Terms and conditions (Payment terms, delivery terms, warranty)", + "Validity period (State how long the quote is valid)", + "Next steps (How they can place an order)", + "Closing paragraph (Express hope to do business, offer further assistance)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be clear, specific, and transparent about pricing, terms, and what's included or excluded. Make it easy for the customer to understand and accept." + }, + "acknowledgment": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line (Acknowledgment of [Received Item/Request])", + "Salutation", + "Acknowledgment statement (Clearly state what you have received or are acknowledging)", + "Details of what's being acknowledged (Reference number, date, brief description)", + "Confirm understanding (Optional: Briefly restate the request/issue to show understanding)", + "Next steps (Outline what will happen next, e.g., processing order, investigating issue)", + "Timeline (Provide an estimated timeframe if possible)", + "Closing paragraph (Express gratitude, offer further assistance)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be prompt, clear, and specific about what you're acknowledging. Set clear expectations for next steps and timelines." + }, + "collection": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line (Invoice [Invoice Number] - Payment Due)", + "Salutation", + "Introduction (Reference invoice number and due date)", + "Account status (Clearly state the outstanding amount)", + "Payment request (Politely request payment)", + "Payment options (Remind them how to pay)", + "Consequences of non-payment (Optional: Briefly mention late fees or further action, depending on letter stage)", + "Call to action (Request payment by a specific date)", + "Closing paragraph (Express hope for prompt payment, offer to discuss)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be firm but professional. Clearly state the amount due, due date, and payment options. The tone may vary depending on how overdue the payment is." + }, + "adjustment": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address (Customer who made a complaint)", + "Subject line (Response to your inquiry - [Reference Number])", + "Salutation", + "Acknowledgment of complaint (Reference their communication and the issue)", + "Investigation findings (Explain the outcome of your investigation)", + "Adjustment offered (Clearly state the resolution: refund, replacement, credit, etc.)", + "Apology (Optional: Express regret for the inconvenience)", + "Preventive measures (Optional: Explain steps taken to prevent recurrence)", + "Closing paragraph (Express hope for continued business, offer further assistance)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be responsive, empathetic, and solution-oriented. Clearly explain the adjustment and any preventive measures taken." + }, + "credit": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address (Applicant)", + "Subject line (Credit Application Status - [Applicant Name])", + "Salutation", + "Introduction (Reference their credit application and the purpose of the letter)", + "Credit decision (Clearly state if credit is approved or denied)", + "If approved: Credit terms (Credit limit, payment terms, interest rates)", + "If denied: Reason for decision (Provide specific, compliant reasons)", + "Requirements (If approved: any further steps or documents needed)", + "Closing paragraph (If approved: Express welcome; If denied: Offer alternative options or appeals process)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be clear, specific, and transparent about the credit decision, terms, limits, or reasons for denial. Ensure compliance with regulations if denying credit." + }, + "follow_up": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line (Following up on [Previous Communication/Meeting])", + "Salutation", + "Reference to previous communication (Mention date, topic, or meeting)", + "Purpose of follow-up (Clearly state why you are writing again)", + "Action items/Next steps (Remind of agreed-upon actions or propose next steps)", + "Provide additional information (Optional)", + "Call to action (If applicable, e.g., request a response, schedule a meeting)", + "Closing paragraph (Reiterate interest, express anticipation)", + "Complimentary close (Professional)", + "Signature (Typed name and title)" + ], + "guidance": "Be clear, specific, and action-oriented. Reference previous communication and clearly state the purpose of your follow-up and desired outcome." + }, + # Default business letter template if subtype is not found + "default": { + "structure": [ + "Letterhead", + "Date", + "Recipient's address", + "Subject line", + "Salutation", + "Introduction", + "Body paragraphs", + "Closing paragraph", + "Complimentary close", + "Signature" + ], + "guidance": "Be professional, clear, and concise. Focus on the business purpose of your letter. The tone is typically formal to semi-formal." + } + }, + "cover": { + "standard": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information (if known)", + "Subject line (Job Application - [Your Name] - [Job Title])", + "Salutation (Formal)", + "Introduction (State the position you are applying for, where you saw the advertisement, and a brief statement of enthusiasm)", + "Body paragraph 1 (Highlight skills and experience directly relevant to the job description - often 1-2 key qualifications)", + "Body paragraph 2 (Provide a specific example or anecdote demonstrating your abilities)", + "Body paragraph 3 (Connect your passion/goals to the company's mission/values - optional but effective)", + "Closing paragraph (Reiterate interest, mention attached resume, express availability for interview)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Be professional, specific about your most relevant qualifications, and clear about your interest in the position. Tailor every cover letter to the specific job and company." + }, + "career_change": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Job Application - [Your Name] - [Job Title])", + "Salutation", + "Introduction (State the position and acknowledge your career transition)", + "Body paragraph 1 (Highlight transferable skills from previous roles)", + "Body paragraph 2 (Explain your motivation for the career change and how your skills apply)", + "Body paragraph 3 (Demonstrate understanding of the new industry/role)", + "Closing paragraph (Reiterate enthusiasm, mention enclosed resume, call to action)", + "Complimentary close", + "Signature" + ], + "guidance": "Focus on transferable skills and explain your career transition. Connect your past experience and new skills directly to the requirements of the target role." + }, + "entry_level": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Job Application - [Your Name] - [Job Title])", + "Salutation", + "Introduction (State the position and your enthusiasm for the opportunity as a recent graduate/entrant)", + "Body paragraph 1 (Highlight relevant education, coursework, GPA if strong)", + "Body paragraph 2 (Describe relevant internships, projects, or volunteer experience)", + "Body paragraph 3 (Showcase soft skills: teamwork, communication, eagerness to learn)", + "Closing paragraph (Reiterate interest, mention attached resume, express availability for interview)", + "Complimentary close", + "Signature" + ], + "guidance": "Emphasize education, relevant internships/projects, and transferable skills gained through academic or extracurricular activities. Show strong potential and enthusiasm." + }, + "executive": { + "structure": [ + "Your contact information", + "Date", + "Recipient's contact information (Senior Executive/Board Member)", + "Subject line (Executive Application - [Your Name] - [Position])", + "Salutation (Formal)", + "Introduction (State position applying for, brief summary of executive profile)", + "Body paragraph 1 (Highlight strategic leadership experience and key achievements)", + "Body paragraph 2 (Discuss relevant industry expertise and market insights)", + "Body paragraph 3 (Describe experience in driving growth, managing teams, achieving results)", + "Closing paragraph (Reiterate interest, express desire to discuss contribution to the organization)", + "Complimentary close (Formal)", + "Signature" + ], + "guidance": "Emphasize strategic leadership experience, significant achievements with measurable results, and industry expertise. Use a confident, authoritative, and forward-looking tone." + }, + "creative": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Application - [Your Name] - [Creative Role])", + "Salutation", + "Creative introduction (Engaging hook related to the role or your passion)", + "Body paragraph 1 (Highlight relevant creative experience and skills)", + "Body paragraph 2 (Reference specific portfolio pieces or projects that showcase your style/abilities)", + "Body paragraph 3 (Describe your creative process or approach)", + "Closing paragraph (Reiterate enthusiasm, mention attached resume/portfolio link, call to action)", + "Complimentary close", + "Signature" + ], + "guidance": "Use a more engaging and expressive style appropriate for a creative role while maintaining professionalism. Highlight specific creative achievements and link to your portfolio." + }, + "technical": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Application - [Your Name] - [Technical Role])", + "Salutation (Formal)", + "Introduction (State position, source, and brief technical interest)", + "Body paragraph 1 (Highlight specific technical skills and proficiencies relevant to the job description)", + "Body paragraph 2 (Describe relevant technical projects or challenges you've solved)", + "Body paragraph 3 (Discuss problem-solving abilities and experience with relevant technologies)", + "Closing paragraph (Reiterate interest, mention attached resume, express availability for technical discussion/interview)", + "Complimentary close (Formal)", + "Signature" + ], + "guidance": "Focus on technical skills, relevant projects, and problem-solving abilities. Use appropriate technical terminology accurately." + }, + "academic": { + "structure": [ + "Your contact information", + "Date", + "Recipient's contact information (Search Committee Chair)", + "Subject line (Application for [Position] - [Your Name])", + "Salutation (Formal)", + "Introduction (State the position, the department, and express your strong interest)", + "Body paragraph 1 (Discuss your research experience, focus on key projects and contributions)", + "Body paragraph 2 (Describe your teaching philosophy and relevant teaching experience)", + "Body paragraph 3 (Mention publications, presentations, grants, and other scholarly contributions)", + "Closing paragraph (Reiterate enthusiasm for joining the faculty, express availability for interview/presentation)", + "Complimentary close (Formal)", + "Signature (Typed name)" + ], + "guidance": "Focus on research experience, teaching philosophy, publications, and contributions to the field. Use a scholarly and professional tone suitable for academia." + }, + "remote": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Remote Application - [Your Name] - [Job Title])", + "Salutation", + "Introduction (State the remote position, source, and enthusiasm for remote work)", + "Body paragraph 1 (Highlight experience working remotely or independently)", + "Body paragraph 2 (Emphasize self-management, time management, and organizational skills required for remote work)", + "Body paragraph 3 (Describe strong written and verbal communication skills, essential for remote collaboration)", + "Closing paragraph (Reiterate interest in the remote role, mention attached resume, express availability for video interview)", + "Complimentary close", + "Signature" + ], + "guidance": "Emphasize self-motivation, excellent communication skills (especially written), time management, and any prior experience working independently or in remote teams." + }, + "referral": { + "structure": [ + "Your contact information", + "Date", + "Hiring Manager contact information", + "Subject line (Referral Application - [Your Name] - [Job Title] - Referred by [Referrer's Name])", + "Salutation", + "Referral introduction (Immediately state who referred you and for what position)", + "Body paragraph 1 (Briefly explain your connection to the referrer and how you learned about the role)", + "Body paragraph 2 (Highlight key qualifications relevant to the job description)", + "Body paragraph 3 (Express strong interest in the position and the company)", + "Closing paragraph (Reiterate enthusiasm, mention attached resume, express availability for interview)", + "Complimentary close", + "Signature" + ], + "guidance": "Mention the referral prominently and early. Explain your connection to the referrer and how it aligns with your interest in the role. Still, ensure you highlight your own qualifications." + }, + # Default cover letter template if subtype is not found + "default": { + "structure": [ + "Contact information", + "Date", + "Recipient's information", + "Subject line", + "Salutation", + "Introduction", + "Body paragraphs", + "Closing paragraph", + "Complimentary close", + "Signature" + ], + "guidance": "Be professional, specific about your qualifications, and clear about your interest in the position. Tailor your letter to the specific job and company." + } + }, + "recommendation": { + # Recommendation letters are often considered a subtype of Formal, + # but can be a top-level type in some systems. Keeping the structure + # consistent with the original request, but noting this potential overlap. + "standard": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information (e.g., Admissions Committee, Hiring Manager)", + "Subject line (Letter of Recommendation for [Name])", + "Salutation (Formal)", + "Introduction (State your name, title, relationship to the recommendee, how long you've known them, and for what opportunity the letter is written)", + "Body paragraph 1 (Describe their relevant skills and qualities, providing specific examples)", + "Body paragraph 2 (Discuss their achievements or contributions, with context and impact)", + "Body paragraph 3 (Optional: Mention character traits, teamwork, or specific anecdotes)", + "Overall Endorsement (Summarize your strong recommendation and why they are a good fit)", + "Closing paragraph (Offer to provide further information)", + "Complimentary close (Formal)", + "Signature (Typed name and title)" + ], + "guidance": "Be specific, positive, and credible. Use concrete examples and anecdotes to support your recommendation. Clearly state your relationship with the person and for what opportunity you are recommending them." + }, + # Default recommendation letter template if subtype is not found + "default": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Letter of Recommendation for [Name])", + "Salutation", + "Introduction", + "Body paragraphs describing qualifications and experiences", + "Specific examples and anecdotes", + "Overall endorsement and recommendation", + "Closing and offer for further information", + "Complimentary close", + "Signature" + ], + "guidance": "Provide a strong, positive, and specific endorsement based on your professional or academic relationship with the individual." + } + }, + "complaint": { + # Complaint letters are often considered a subtype of Formal or Business, + # but can be a top-level type. Keeping the structure consistent. + "product": { + "structure": [ + "Your contact information", + "Date", + "Company contact information", + "Subject line (Complaint Regarding [Product Name/Model])", + "Salutation (Formal)", + "Introduction (State purpose: complaining about a product, include product name, model, date/place of purchase)", + "Problem description (Explain the specific defect or issue with the product in detail)", + "History of the problem (Mention if you've tried fixing it, contacted support, etc.)", + "Desired resolution (Clearly state if you want a refund, replacement, repair)", + "Call to action (State what you expect the company to do and by when)", + "Closing paragraph (Reference attached documents like receipt, express expectation for resolution)", + "Complimentary close (Formal)", + "Signature" + ], + "guidance": "Be clear, factual, and specific about the product issue and your desired resolution. Include all relevant details like model number, date of purchase, and copies of receipts. Maintain a firm but professional tone." + }, + "service": { + "structure": [ + "Your contact information", + "Date", + "Company/Service Provider contact information", + "Subject line (Complaint Regarding [Service Type/Issue])", + "Salutation (Formal)", + "Introduction (State purpose: complaining about a service received, include date/time/location of service)", + "Problem description (Explain the specific issue with the service provided in detail)", + "Impact of the issue (Explain how this problem affected you)", + "Desired resolution (Clearly state what you want: refund, re-performance of service, compensation)", + "Call to action (State what you expect the company to do and by when)", + "Closing paragraph (Reference any relevant documents, express expectation for resolution)", + "Complimentary close (Formal)", + "Signature" + ], + "guidance": "Be clear, factual, and specific about the service issue and your desired resolution. Include details like dates, times, and names of service providers if possible. Maintain a firm but professional tone." + }, + "billing": { + "structure": [ + "Your contact information", + "Date", + "Company contact information", + "Subject line (Complaint Regarding Billing Error - Account #[Your Account Number])", + "Salutation (Formal)", + "Introduction (State purpose: complaining about a billing error, include account number and invoice number)", + "Problem description (Explain the specific error on the bill: incorrect charge, double billing, etc.)", + "Provide supporting evidence (Reference payments made, attach relevant statements)", + "Desired resolution (Clearly state what you want: correction of bill, refund, credit)", + "Call to action (State what you expect the company to do and by when)", + "Closing paragraph (Reference attached documents, express expectation for resolution)", + "Complimentary close (Formal)", + "Signature" + ], + "guidance": "Be clear, factual, and specific about the billing error. Provide supporting documentation like invoices or payment records. Clearly state the desired correction." + }, + # Default complaint letter template if subtype is not found + "default": { + "structure": [ + "Your contact information", + "Date", + "Recipient's contact information", + "Subject line (Complaint Regarding [Issue Summary])", + "Salutation", + "Introduction (State the purpose of the letter - to complain)", + "Detailed description of the problem", + "Explanation of the impact", + "Desired resolution", + "Call to action", + "Closing", + "Signature" + ], + "guidance": "Be clear, factual, and specific about the issue and your desired resolution. Maintain a respectful but firm tone and provide relevant details." + } + }, + "thank_you": { + # Thank You letters are often considered a subtype of Personal or Business, + # but can be a top-level type. Keeping the structure consistent. + "personal": { + "structure": [ + "Greeting", + "Express gratitude clearly and sincerely", + "Specify what you are thankful for (gift, favor, support)", + "Explain the impact it had on you or how you used it", + "Share a personal thought or memory related to it (optional)", + "Look to the future or express continued appreciation", + "Closing" + ], + "guidance": "Be warm, sincere, and specific about what you are thankful for. Personalize the message and explain the impact of their action or gift." + }, + "professional": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Thank You - [Your Name])", + "Salutation (Formal/Semi-formal)", + "Express gratitude clearly (e.g., Thank you for the interview, thank you for your help)", + "Specify what you are thankful for (Meeting date/topic, specific assistance)", + "Reiterate interest or connection (e.g., Reiterate interest in the job, mention something discussed)", + "Express appreciation for their time or effort", + "Closing paragraph (Optional: look to future interaction)", + "Complimentary close (Formal/Semi-formal)", + "Signature" + ], + "guidance": "Be prompt, professional, and specific. Reiterate your interest or key points discussed. Send within 24 hours for interviews." + }, + "after_interview": { + "structure": [ + "Your contact information", + "Date", + "Interviewer's contact information", + "Subject line (Thank You - [Your Name] - [Job Title])", + "Salutation (Formal)", + "Express sincere thanks for the interview opportunity", + "Mention the specific position and date of the interview", + "Reiterate your strong interest in the role and the company", + "Reference a specific point discussed during the interview to show engagement", + "Briefly highlight how your skills/experience align with a need discussed", + "Express enthusiasm for next steps", + "Complimentary close (Formal)", + "Signature" + ], + "guidance": "Send within 24 hours of the interview. Be specific, professional, and reiterate your key strengths and interest. Proofread carefully." + }, + # Default thank you letter template if subtype is not found + "default": { + "structure": [ + "Greeting", + "Express thanks", + "Specify reason for thanks", + "Closing" + ], + "guidance": "Be sincere and specific about what you are thankful for." + } + }, + "invitation": { + # Invitation letters are often considered a subtype of Personal or Formal, + # but can be a top-level type. Keeping the structure consistent. + "event": { # e.g., party, gathering, wedding + "structure": [ + "Greeting", + "State the purpose: extending an invitation", + "Event details (Type of event, Host)", + "Date and Time", + "Location (Full address)", + "Purpose/Theme (Optional)", + "Special instructions (Dress code, what to bring, etc. - optional)", + "RSVP information (Date, Contact method)", + "Express anticipation", + "Closing" + ], + "guidance": "Be clear about all the event details (What, When, Where). Make it easy for guests to RSVP. Tone can be formal or informal depending on the event." + }, + "interview": { + "structure": [ + "Company Letterhead", + "Date", + "Candidate's contact information", + "Subject line (Interview Invitation - [Job Title] - [Your Name])", + "Salutation (Formal)", + "State the purpose: inviting them for an interview", + "Specify the position applied for", + "Propose date(s) and time(s) for the interview", + "Provide location details (Address, or link for virtual)", + "Mention who they will meet with (Names and titles)", + "Explain the interview format/duration (Optional)", + "Instructions (What to bring, who to contact with questions)", + "Call to action (Request confirmation or scheduling)", + "Closing paragraph (Express anticipation)", + "Complimentary close (Formal)", + "Signature (Interviewer/HR Contact Name and Title)" + ], + "guidance": "Be professional, clear, and provide all necessary details for the candidate. Make the scheduling process straightforward." + }, + "meeting": { + "structure": [ + "Sender's contact information", + "Date", + "Recipient's contact information", + "Subject line (Invitation to Meeting - [Meeting Topic])", + "Salutation", + "State the purpose: inviting them to a meeting", + "Meeting details (Date, Time, Location/Virtual link)", + "Purpose/Agenda (Clearly state what the meeting is about)", + "Expected duration (Optional)", + "Preparation required (Optional: Documents to review)", + "RSVP information (Optional)", + "Closing paragraph", + "Complimentary close", + "Signature" + ], + "guidance": "Be clear about the purpose, date, time, and location. Provide an agenda so attendees can prepare. The tone can be formal or informal depending on the context." + }, + # Default invitation letter template if subtype is not found + "default": { + "structure": [ + "Greeting", + "Invitation statement", + "Event/Meeting details (What, When, Where)", + "Purpose (Optional)", + "RSVP information", + "Closing" + ], + "guidance": "Be clear and specific about the details of the event or meeting." + } + }, + # Overall default template if letter type is not recognized + "default": { + "structure": [ + "Introduction", + "Body paragraphs", + "Conclusion" + ], + "guidance": "Be clear, concise, and appropriate for your audience and purpose. This is a generic structure." + } +} + + +def get_template_by_type(letter_type: str, subtype: str = "default") -> Dict[str, Any]: + """ + Get a template for a specific letter type and subtype using a dictionary lookup. + + Args: + letter_type: Type of letter (e.g., "personal", "formal", "business", "cover"). + subtype: Subtype of letter (e.g., "congratulations", "application", "sales"). + Defaults to "default" if no subtype is specified. + + Returns: + Template dictionary with 'structure' (List[str]) and 'guidance' (str). + Returns the default template if the letter type or subtype is not found. + """ + # Get templates for the specific letter type, or the overall default templates + type_templates = TEMPLATES.get(letter_type, TEMPLATES["default"]) + + # Get the template for the specific subtype, or the default for that letter type + template = type_templates.get(subtype, type_templates.get("default", TEMPLATES["default"])) # Fallback to overall default + + # Ensure the returned template always has 'structure' and 'guidance' keys + # This handles cases where an incomplete template might have been defined (error tolerance) + if "structure" not in template or not isinstance(template["structure"], list): + template["structure"] = ["Introduction", "Body", "Conclusion"] + template["guidance"] = "Generic template: structure or guidance missing." + + if "guidance" not in template or not isinstance(template["guidance"], str): + template["guidance"] = "Generic guidance: structure or guidance missing." + + + return template + +# Example usage (for testing purposes) +if __name__ == '__main__': + # Test cases + print("--- Testing Letter Templates ---") + + personal_congrats = get_template_by_type("personal", "congratulations") + print("\nPersonal Congratulations Template:") + print(f"Structure: {personal_congrats['structure']}") + print(f"Guidance: {personal_congrats['guidance']}") + + formal_complaint = get_template_by_type("formal", "complaint") + print("\nFormal Complaint Template:") + print(f"Structure: {formal_complaint['structure']}") + print(f"Guidance: {formal_complaint['guidance']}") + + business_sales = get_template_by_type("business", "sales") + print("\nBusiness Sales Template:") + print(f"Structure: {business_sales['structure']}") + print(f"Guidance: {business_sales['guidance']}") + + cover_entry_level = get_template_by_type("cover", "entry_level") + print("\nCover Entry Level Template:") + print(f"Structure: {cover_entry_level['structure']}") + print(f"Guidance: {cover_entry_level['guidance']}") + + unknown_type = get_template_by_type("unknown_type", "some_subtype") + print("\nUnknown Type Template (Should be Default):") + print(f"Structure: {unknown_type['structure']}") + print(f"Guidance: {unknown_type['guidance']}") + + personal_unknown_subtype = get_template_by_type("personal", "unknown_subtype") + print("\nPersonal Unknown Subtype Template (Should be Personal Default):") + print(f"Structure: {personal_unknown_subtype['structure']}") + print(f"Guidance: {personal_unknown_subtype['guidance']}") \ No newline at end of file diff --git a/lib/ai_writers/ai_outline_writer/README.md b/lib/ai_writers/ai_outline_writer/README.md new file mode 100644 index 00000000..2cd573c1 --- /dev/null +++ b/lib/ai_writers/ai_outline_writer/README.md @@ -0,0 +1,557 @@ +# Blog Outline Generator + +A powerful AI-powered tool for generating comprehensive blog outlines with advanced editing capabilities, content generation, and image integration. + +## 🛠 Technical Architecture + +### Core Components +- **Backend**: Python-based implementation using Streamlit for UI +- **AI Integration**: + - Text Generation: Integration with multiple LLM providers (Gemini, OpenAI, Anthropic) + - Image Generation: Support for multiple image generation APIs (Gemini-AI, Dalle3, Stability-AI) +- **Data Structures**: + ```python + class OutlineConfig: + content_type: ContentType + content_depth: ContentDepth + outline_style: OutlineStyle + target_word_count: int + num_main_sections: int + num_subsections_per_section: int + include_images: bool + image_style: str + image_engine: str + ``` + +### Key Technologies +- **Streamlit**: Web application framework +- **Asyncio**: Asynchronous operations for AI calls +- **Loguru**: Advanced logging system +- **BeautifulSoup**: Web content parsing +- **Pydantic**: Data validation +- **Markdown**: Content formatting + +## 🌟 Features with Examples + +### 1. Content Generation +- **AI-Powered Content Creation**: + ```python + # Example prompt for content generation + prompt = f""" + Generate content for a {content_type} article about {topic}. + Target audience: {target_audience} + Word count: {target_word_count} + Style: {outline_style} + """ + content = await llm_text_gen(prompt) + ``` + +- **Multiple Content Types**: + ```python + # Example configuration for different content types + config = OutlineConfig( + content_type=ContentType.TUTORIAL, + content_depth=ContentDepth.INTERMEDIATE, + target_word_count=2000 + ) + ``` + +### 2. Outline Structure +- **Flexible Section Management**: + ```python + # Example section generation + async def generate_sections(self, topic: str) -> List[str]: + sections = [] + for i in range(self.config.num_main_sections): + section = await self._generate_section(topic, i) + sections.append(section) + return sections + ``` + +- **Optional Components**: + ```python + # Example FAQ generation + async def generate_faqs(self, topic: str) -> List[str]: + prompt = f""" + Generate 5 common questions about {topic} + Content type: {self.config.content_type} + Target audience: {self.config.target_audience} + """ + return await llm_text_gen(prompt) + ``` + +### 3. Advanced Editing Capabilities +- **Section Content Editor**: + ```python + # Example content editing interface + def edit_section_content(self, section: str, content: str) -> str: + edited_content = st.text_area( + "Edit Content", + value=content, + height=300, + key=f"content_edit_{section}" + ) + return edited_content + ``` + +- **Subsection Management**: + ```python + # Example subsection reordering + def reorder_subsections(self, section: str, subsections: List[str]) -> List[str]: + for i, subsection in enumerate(subsections): + if st.button("↑", key=f"move_up_{section}_{i}"): + subsections[i], subsections[i-1] = subsections[i-1], subsections[i] + return subsections + ``` + +### 4. Image Generation +- **AI Image Generation**: + ```python + # Example image generation + async def generate_image(self, prompt: str, style: str) -> str: + image_prompt = f""" + Create a {style} image for: {prompt} + Style: {self.config.image_style} + """ + return await generate_image(image_prompt) + ``` + +### 5. Content Optimization +- **SEO Features**: + ```python + # Example SEO optimization + def optimize_content(self, content: str, keywords: List[str]) -> str: + for keyword in keywords: + content = self._naturally_insert_keyword(content, keyword) + return content + ``` + +## 📊 Technical Implementation Details + +### 1. Content Generation Pipeline +```python +async def generate_content(self, topic: str) -> Dict: + # 1. Generate outline structure + outline = await self.generate_outline(topic) + + # 2. Generate content for each section + for section in outline: + content = await self.generate_section_content(section) + outline[section]['content'] = content + + # 3. Generate images if enabled + if self.config.include_images: + for section in outline: + image = await self.generate_section_image(section) + outline[section]['image'] = image + + return outline +``` + +### 2. AI Integration +```python +class AIIntegration: + def __init__(self, provider: str): + self.provider = provider + self.model = self._initialize_model() + + async def generate_text(self, prompt: str) -> str: + if self.provider == "gemini": + return await gemini_text_response(prompt) + elif self.provider == "openai": + return await openai_chatgpt(prompt) +``` + +### 3. Image Processing +```python +class ImageProcessor: + def __init__(self, engine: str): + self.engine = engine + + async def generate_image(self, prompt: str) -> str: + if self.engine == "Gemini-AI": + return await generate_gemini_image(prompt) + elif self.engine == "Dalle3": + return await generate_dalle3_images(prompt) +``` + +## 🔧 Configuration Examples + +### 1. Basic Configuration +```python +config = OutlineConfig( + content_type=ContentType.GUIDE, + content_depth=ContentDepth.INTERMEDIATE, + target_word_count=2000, + num_main_sections=5, + num_subsections_per_section=3 +) +``` + +### 2. Advanced Configuration +```python +config = OutlineConfig( + content_type=ContentType.TUTORIAL, + content_depth=ContentDepth.ADVANCED, + outline_style=OutlineStyle.MODERN, + target_word_count=3000, + include_images=True, + image_style="realistic", + image_engine="Gemini-AI", + target_audience="developers", + language="English", + keywords=["python", "tutorial", "advanced"] +) +``` + +## 📝 Usage Examples + +### 1. Basic Usage +```python +# Initialize generator +generator = BlogOutlineGenerator() + +# Generate outline +outline = await generator.generate_outline("Python Programming Basics") + +# Export to markdown +markdown = generator.to_markdown() +``` + +### 2. Advanced Usage +```python +# Custom configuration +config = OutlineConfig( + content_type=ContentType.TUTORIAL, + content_depth=ContentDepth.ADVANCED, + include_images=True +) + +# Initialize with config +generator = BlogOutlineGenerator(config) + +# Generate with custom settings +outline = await generator.generate_outline( + "Advanced Python Decorators", + keywords=["python", "decorators", "advanced"] +) + +# Export to multiple formats +markdown = generator.to_markdown() +json_output = generator.to_json() +html_output = generator.to_html() +``` + +## 🔍 Technical Considerations + +### 1. Performance Optimization +- Asynchronous operations for AI calls +- Caching of generated content +- Batch processing for images +- Memory management for large documents + +### 2. Error Handling +```python +try: + content = await llm_text_gen(prompt) +except Exception as e: + logger.error(f"Content generation failed: {e}") + return None +``` + +### 3. Data Validation +```python +from pydantic import BaseModel, validator + +class SectionContent(BaseModel): + title: str + content: str + image_path: Optional[str] + + @validator('content') + def validate_content_length(cls, v): + if len(v.split()) < 100: + raise ValueError("Content too short") + return v +``` + +## 🌟 Features + +### 1. Content Generation +- **AI-Powered Content Creation**: Generate high-quality content for each section using advanced language models +- **Multiple Content Types**: Support for various content formats including: + - How-to guides + - Tutorials + - Listicles + - Comparisons + - Case studies + - Opinion pieces + - News articles + - Reviews + - General guides +- **Customizable Content Depth**: + - Basic: Simple, easy-to-understand content + - Intermediate: Balanced depth with practical examples + - Advanced: Detailed technical content + - Expert: In-depth analysis and advanced concepts + +### 2. Outline Structure +- **Flexible Section Management**: + - Customizable number of main sections + - Configurable subsections per section + - Dynamic section reordering + - Easy addition/removal of sections +- **Optional Components**: + - Introduction section + - Conclusion section + - FAQ section + - Additional resources section + +### 3. Advanced Editing Capabilities +- **Section Content Editor**: + - Rich text editing interface + - Real-time word count tracking + - Formatting options (Bold, Italic, Lists, Code Blocks, Links) + - AI-powered content enhancement +- **Subsection Management**: + - Drag-and-drop reordering + - Individual subsection editing + - Add/remove subsection functionality + - Bulk editing capabilities +- **Metadata Editing**: + - Section-specific settings + - Content depth adjustment + - Target word count configuration + - Image settings customization + +### 4. Image Generation +- **AI Image Generation**: + - Multiple image styles (realistic, illustration, minimalist, photographic, artistic) + - Support for multiple image engines (Gemini-AI, Dalle3, Stability-AI) + - Custom image prompts + - Image regeneration capability +- **Image Integration**: + - Automatic image placement + - Image preview and editing + - Image prompt viewing and editing + - Image style customization + +### 5. Content Optimization +- **SEO Features**: + - Keyword integration + - Content structure optimization + - Meta description generation + - SEO-friendly formatting +- **Audience Targeting**: + - Customizable target audience + - Language selection + - Content tone adjustment + - Reading level optimization + +### 6. Export Options +- **Multiple Formats**: + - Markdown export + - JSON export + - HTML export + - Custom formatting options +- **Download Capabilities**: + - One-click download + - Format-specific styling + - Custom file naming + - Batch export options + +### 7. User Interface +- **Intuitive Design**: + - Clean, modern interface + - Responsive layout + - Easy navigation + - Clear visual hierarchy +- **Interactive Features**: + - Real-time preview + - Drag-and-drop functionality + - Quick edit options + - Contextual help + +### 8. Statistics and Analytics +- **Content Metrics**: + - Word count tracking + - Section statistics + - Subsection counts + - Content depth analysis +- **Progress Tracking**: + - Generation progress + - Edit history + - Version comparison + - Performance metrics + +## 🚀 Getting Started + +### Installation +```bash +pip install -r requirements.txt +``` + +### Usage +1. Launch the application: +```bash +streamlit run lib/ai_writers/ai_outline_writer/outline_ui.py +``` + +2. Configure your outline: + - Enter your blog topic + - Select content type and depth + - Choose outline style + - Set target word count + - Configure sections and subsections + +3. Generate and edit: + - Click "Generate Outline" + - Review and edit sections + - Customize content and images + - Export in your preferred format + +## 🔧 Configuration Options + +### Basic Settings +- **Blog Topic**: Main subject of your content +- **Content Type**: Type of content to generate +- **Content Depth**: Level of detail and complexity +- **Outline Style**: Structure and formatting style + +### Advanced Settings +- **Target Word Count**: Desired length of the content +- **Number of Sections**: Customize main sections +- **Subsections**: Configure subsections per section +- **Image Settings**: Customize image generation +- **Target Audience**: Define your audience +- **Language**: Select content language +- **Keywords**: Add SEO keywords +- **Excluded Topics**: Specify topics to avoid + +## 📊 Output Formats + +### 1. Preview Mode +- Interactive preview of the entire outline +- Real-time editing capabilities +- Image preview and management +- Content statistics + +### 2. Markdown Export +- Clean markdown formatting +- Proper heading hierarchy +- Image embedding +- Code block formatting + +### 3. JSON Export +- Structured data format +- Complete outline information +- Content and image metadata +- Configuration details + +### 4. HTML Export +- Styled HTML output +- Responsive design +- Image integration +- Custom CSS support + +## 💡 Best Practices + +### Content Generation +1. Start with a clear topic and target audience +2. Choose appropriate content type and depth +3. Use relevant keywords for SEO +4. Review and edit generated content +5. Add personal insights and examples + +### Outline Structure +1. Maintain logical flow between sections +2. Balance section lengths +3. Include relevant subsections +4. Add appropriate transitions +5. Ensure comprehensive coverage + +### Image Usage +1. Choose appropriate image styles +2. Generate relevant images +3. Optimize image placement +4. Review image prompts +5. Consider image licensing + +## 🔄 Workflow + +1. **Initial Setup** + - Configure basic settings + - Set content parameters + - Define target audience + +2. **Generation** + - Generate initial outline + - Review structure + - Generate content + - Create images + +3. **Editing** + - Review and edit content + - Adjust structure + - Customize images + - Optimize for SEO + +4. **Export** + - Choose export format + - Review final output + - Download content + - Save configuration + +## 📝 Tips and Tricks + +### Content Generation +- Use specific keywords for better results +- Provide clear context for the AI +- Review and refine generated content +- Add personal expertise + +### Structure Optimization +- Maintain consistent section lengths +- Use clear subsection hierarchies +- Include relevant examples +- Add practical applications + +### Image Enhancement +- Use descriptive image prompts +- Experiment with different styles +- Consider image placement +- Review image relevance + +## 🤝 Contributing + +We welcome contributions! Please follow these steps: +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +## 📄 License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## 📞 Support + +For support, please: +1. Check the documentation +2. Review existing issues +3. Create a new issue if needed +4. Contact the maintainers + +## 🔮 Future Enhancements + +Planned features: +- Multi-language support +- Advanced AI models +- More export formats +- Enhanced editing tools +- Collaboration features +- Version control integration +- Analytics dashboard +- Custom templates +- API integration +- Mobile optimization \ No newline at end of file diff --git a/lib/ai_writers/ai_outline_writer/get_blog_outline.py b/lib/ai_writers/ai_outline_writer/get_blog_outline.py new file mode 100644 index 00000000..83bba9fc --- /dev/null +++ b/lib/ai_writers/ai_outline_writer/get_blog_outline.py @@ -0,0 +1,336 @@ +""" +Enhanced Blog Outline Generator + +This module provides a sophisticated outline generation system that creates detailed, +well-structured outlines for blog posts based on user preferences and content requirements. +""" + +import sys +from typing import Dict, List, Optional +from enum import Enum +from dataclasses import dataclass +from loguru import logger + +from lib.gpt_providers.text_generation.main_text_generation import llm_text_gen +from lib.gpt_providers.text_to_image_generation.main_generate_image_from_prompt import generate_image + +logger.remove() +logger.add(sys.stdout, + colorize=True, + format="{level}|{file}:{line}:{function}| {message}") + +class ContentType(Enum): + """Types of content that can be generated.""" + HOW_TO = "how-to" + TUTORIAL = "tutorial" + LISTICLE = "listicle" + COMPARISON = "comparison" + CASE_STUDY = "case-study" + OPINION = "opinion" + NEWS = "news" + REVIEW = "review" + GUIDE = "guide" + +class ContentDepth(Enum): + """Depth levels for content coverage.""" + BASIC = "basic" + INTERMEDIATE = "intermediate" + ADVANCED = "advanced" + EXPERT = "expert" + +class OutlineStyle(Enum): + """Styles for outline structure.""" + TRADITIONAL = "traditional" + MODERN = "modern" + CONVERSATIONAL = "conversational" + ACADEMIC = "academic" + SEO_OPTIMIZED = "seo-optimized" + +@dataclass +class OutlineConfig: + """Configuration for outline generation.""" + content_type: ContentType = ContentType.GUIDE + content_depth: ContentDepth = ContentDepth.INTERMEDIATE + outline_style: OutlineStyle = OutlineStyle.MODERN + target_word_count: int = 2000 + num_main_sections: int = 5 + num_subsections_per_section: int = 3 + include_introduction: bool = True + include_conclusion: bool = True + include_faqs: bool = True + include_resources: bool = True + target_audience: str = "general" + language: str = "English" + keywords: List[str] = None + exclude_topics: List[str] = None + include_images: bool = True + image_style: str = "realistic" + image_engine: str = "Gemini-AI" + +@dataclass +class SectionContent: + """Content for a section including text and image.""" + title: str + content: str + image_prompt: Optional[str] = None + image_path: Optional[str] = None + +class BlogOutlineGenerator: + """Enhanced blog outline generator with comprehensive controls.""" + + def __init__(self, config: Optional[OutlineConfig] = None): + """Initialize the outline generator with optional configuration.""" + self.config = config or OutlineConfig() + self.outline = {} + self.section_contents = {} + + async def generate_outline(self, topic: str) -> Dict: + """Generate a comprehensive outline based on the topic and configuration.""" + try: + # Step 1: Generate main sections + main_sections = await self._generate_main_sections(topic) + + # Step 2: Generate subsections for each main section + detailed_sections = await self._generate_subsections(main_sections) + + # Step 3: Add introduction and conclusion if requested + if self.config.include_introduction: + detailed_sections["Introduction"] = await self._generate_introduction(topic) + + if self.config.include_conclusion: + detailed_sections["Conclusion"] = await self._generate_conclusion(topic) + + # Step 4: Add FAQs if requested + if self.config.include_faqs: + detailed_sections["FAQs"] = await self._generate_faqs(topic) + + # Step 5: Add resources if requested + if self.config.include_resources: + detailed_sections["Additional Resources"] = await self._generate_resources(topic) + + self.outline = detailed_sections + + # Step 6: Generate content for each section + await self._generate_section_contents(topic) + + return self.outline + + except Exception as err: + logger.error(f"Failed to generate outline: {err}") + raise + + async def _generate_main_sections(self, topic: str) -> List[str]: + """Generate main sections for the outline.""" + prompt = f"""Generate {self.config.num_main_sections} main sections for a {self.config.content_type.value} + article about {topic} with the following characteristics: + + Content Type: {self.config.content_type.value} + Content Depth: {self.config.content_depth.value} + Target Word Count: {self.config.target_word_count} + Target Audience: {self.config.target_audience} + Style: {self.config.outline_style.value} + + Additional Requirements: + - Each section should contribute to the overall word count goal + - Sections should flow logically + - Include key concepts and important points + - Consider SEO optimization + - Keywords to include: {', '.join(self.config.keywords or [])} + - Topics to exclude: {', '.join(self.config.exclude_topics or [])} + + Please provide only the section titles, one per line.""" + + response = await llm_text_gen(prompt) + return [section.strip() for section in response.split('\n') if section.strip()] + + async def _generate_subsections(self, main_sections: List[str]) -> Dict[str, List[str]]: + """Generate subsections for each main section.""" + detailed_sections = {} + + for section in main_sections: + prompt = f"""Generate {self.config.num_subsections_per_section} subsections for the following section: + {section} + + Content Type: {self.config.content_type.value} + Content Depth: {self.config.content_depth.value} + Style: {self.config.outline_style.value} + + Each subsection should: + - Be specific and focused + - Support the main section's topic + - Include key points to cover + - Consider SEO optimization + + Please provide only the subsection titles, one per line.""" + + response = await llm_text_gen(prompt) + detailed_sections[section] = [sub.strip() for sub in response.split('\n') if sub.strip()] + + return detailed_sections + + async def _generate_introduction(self, topic: str) -> List[str]: + """Generate introduction subsections.""" + prompt = f"""Generate introduction subsections for an article about {topic}. + + Content Type: {self.config.content_type.value} + Content Depth: {self.config.content_depth.value} + Style: {self.config.outline_style.value} + + The introduction should: + - Hook the reader + - Present the main topic + - Outline what's to come + - Set the tone for the article + + Please provide only the subsection titles, one per line.""" + + response = await llm_text_gen(prompt) + return [sub.strip() for sub in response.split('\n') if sub.strip()] + + async def _generate_conclusion(self, topic: str) -> List[str]: + """Generate conclusion subsections.""" + prompt = f"""Generate conclusion subsections for an article about {topic}. + + Content Type: {self.config.content_type.value} + Content Depth: {self.config.content_depth.value} + Style: {self.config.outline_style.value} + + The conclusion should: + - Summarize key points + - Provide final thoughts + - Include a call to action + - Leave a lasting impression + + Please provide only the subsection titles, one per line.""" + + response = await llm_text_gen(prompt) + return [sub.strip() for sub in response.split('\n') if sub.strip()] + + async def _generate_faqs(self, topic: str) -> List[str]: + """Generate FAQ subsections.""" + prompt = f"""Generate FAQ subsections for an article about {topic}. + + Content Type: {self.config.content_type.value} + Content Depth: {self.config.content_depth.value} + Style: {self.config.outline_style.value} + + The FAQs should: + - Address common questions + - Cover important aspects + - Be relevant to the target audience + - Include both basic and advanced questions + + Please provide only the FAQ questions, one per line.""" + + response = await llm_text_gen(prompt) + return [sub.strip() for sub in response.split('\n') if sub.strip()] + + async def _generate_resources(self, topic: str) -> List[str]: + """Generate resource subsections.""" + prompt = f"""Generate resource subsections for an article about {topic}. + + Content Type: {self.config.content_type.value} + Content Depth: {self.config.content_depth.value} + Style: {self.config.outline_style.value} + + The resources should: + - Include relevant links + - Suggest further reading + - Provide tools or references + - Include related materials + + Please provide only the resource categories, one per line.""" + + response = await llm_text_gen(prompt) + return [sub.strip() for sub in response.split('\n') if sub.strip()] + + async def _generate_section_contents(self, topic: str): + """Generate content and images for each section.""" + for section, subsections in self.outline.items(): + if section not in ["Introduction", "Conclusion", "FAQs", "Additional Resources"]: + # Generate content for the main section + content_prompt = f"""Write a detailed section for a blog post about {topic}. + Section Title: {section} + Content Type: {self.config.content_type.value} + Content Depth: {self.config.content_depth.value} + Style: {self.config.outline_style.value} + Target Word Count: {self.config.target_word_count // self.config.num_main_sections} + + Include: + - Clear explanation of the main points + - Examples and illustrations + - Key takeaways + - Relevant data or statistics + """ + + content = await llm_text_gen(content_prompt) + + # Generate image prompt if images are enabled + image_prompt = None + image_path = None + + if self.config.include_images: + image_prompt = f"""Create a detailed image prompt for a blog section about {topic}. + Section: {section} + Content: {content[:200]}... + Style: {self.config.image_style} + """ + + image_prompt = await llm_text_gen(image_prompt) + try: + image_path = generate_image( + image_prompt, + title=section, + description=content[:100], + tags=self.config.keywords + ) + except Exception as err: + logger.warning(f"Failed to generate image for section {section}: {err}") + + self.section_contents[section] = SectionContent( + title=section, + content=content, + image_prompt=image_prompt, + image_path=image_path + ) + + def to_markdown(self) -> str: + """Convert outline to markdown format with content and images.""" + markdown = f"# {self.outline.get('Introduction', [''])[0]}\n\n" + + for section, subsections in self.outline.items(): + if section not in ["Introduction", "Conclusion", "FAQs", "Additional Resources"]: + markdown += f"## {section}\n\n" + + # Add section content if available + if section in self.section_contents: + content = self.section_contents[section] + markdown += f"{content.content}\n\n" + + # Add image if available + if content.image_path: + markdown += f"![{section}]({content.image_path})\n\n" + + # Add subsections + for subsection in subsections: + markdown += f"- {subsection}\n" + markdown += "\n" + + if "Conclusion" in self.outline: + markdown += "## Conclusion\n\n" + for subsection in self.outline["Conclusion"]: + markdown += f"- {subsection}\n" + markdown += "\n" + + if "FAQs" in self.outline: + markdown += "## Frequently Asked Questions\n\n" + for faq in self.outline["FAQs"]: + markdown += f"- {faq}\n" + markdown += "\n" + + if "Additional Resources" in self.outline: + markdown += "## Additional Resources\n\n" + for resource in self.outline["Additional Resources"]: + markdown += f"- {resource}\n" + + return markdown diff --git a/lib/ai_writers/ai_outline_writer/outline_ui.py b/lib/ai_writers/ai_outline_writer/outline_ui.py new file mode 100644 index 00000000..cea46936 --- /dev/null +++ b/lib/ai_writers/ai_outline_writer/outline_ui.py @@ -0,0 +1,489 @@ +""" +Streamlit UI for Enhanced Blog Outline Generator + +This module provides a user-friendly interface for generating comprehensive blog outlines +with AI-powered content and image generation capabilities. +""" + +import streamlit as st +import asyncio +from pathlib import Path +from typing import Optional, Dict, List +import json +import time +from datetime import datetime + +from .get_blog_outline import ( + BlogOutlineGenerator, + OutlineConfig, + ContentType, + ContentDepth, + OutlineStyle +) + +# Custom CSS for better styling +st.markdown(""" + +""", unsafe_allow_html=True) + +def edit_section_content(section: str, content: str) -> str: + """Edit section content with advanced options.""" + st.markdown('
', unsafe_allow_html=True) + + # Content editing + edited_content = st.text_area( + "Edit Content", + value=content, + height=300, + key=f"content_edit_{section}" + ) + + # Word count and formatting + col1, col2 = st.columns(2) + with col1: + word_count = len(edited_content.split()) + st.info(f"Word Count: {word_count}") + + with col2: + formatting = st.multiselect( + "Formatting Options", + ["Bold", "Italic", "Lists", "Code Blocks", "Links"], + key=f"format_{section}" + ) + + # AI enhancement options + with st.expander("AI Enhancement Options"): + enhance_options = st.multiselect( + "Select Enhancements", + ["Improve Clarity", "Add Examples", "Expand Details", "Add Statistics", "Improve SEO"], + key=f"enhance_{section}" + ) + + if st.button("Apply Enhancements", key=f"apply_enhance_{section}"): + with st.spinner("Applying enhancements..."): + # TODO: Implement AI enhancement logic + st.success("Enhancements applied!") + + st.markdown('
', unsafe_allow_html=True) + return edited_content + +def edit_subsections(section: str, subsections: List[str]) -> List[str]: + """Edit subsections with reordering and editing capabilities.""" + st.markdown('
', unsafe_allow_html=True) + + # Reorder subsections + st.markdown("### Reorder Subsections") + for i, subsection in enumerate(subsections): + col1, col2 = st.columns([4, 1]) + with col1: + subsections[i] = st.text_input( + f"Subsection {i+1}", + value=subsection, + key=f"subsection_{section}_{i}" + ) + with col2: + if st.button("↑", key=f"move_up_{section}_{i}") and i > 0: + subsections[i], subsections[i-1] = subsections[i-1], subsections[i] + st.experimental_rerun() + if st.button("↓", key=f"move_down_{section}_{i}") and i < len(subsections)-1: + subsections[i], subsections[i+1] = subsections[i+1], subsections[i] + st.experimental_rerun() + + # Add/remove subsections + col1, col2 = st.columns(2) + with col1: + if st.button("Add Subsection", key=f"add_sub_{section}"): + subsections.append("New Subsection") + st.experimental_rerun() + with col2: + if st.button("Remove Last Subsection", key=f"remove_sub_{section}"): + if subsections: + subsections.pop() + st.experimental_rerun() + + st.markdown('
', unsafe_allow_html=True) + return subsections + +def edit_section_metadata(section: str, generator: BlogOutlineGenerator): + """Edit section metadata and settings.""" + st.markdown('
', unsafe_allow_html=True) + + # Section settings + st.markdown("### Section Settings") + + # Image settings + if generator.config.include_images: + col1, col2 = st.columns(2) + with col1: + new_image_style = st.selectbox( + "Image Style", + ["realistic", "illustration", "minimalist", "photographic", "artistic"], + key=f"img_style_{section}" + ) + with col2: + new_image_engine = st.selectbox( + "Image Engine", + ["Gemini-AI", "Dalle3", "Stability-AI"], + key=f"img_engine_{section}" + ) + + if st.button("Regenerate Image", key=f"regen_img_{section}"): + with st.spinner("Regenerating image..."): + # TODO: Implement image regeneration logic + st.success("Image regenerated!") + + # Content settings + st.markdown("### Content Settings") + col1, col2 = st.columns(2) + with col1: + target_word_count = st.number_input( + "Target Word Count", + min_value=100, + max_value=2000, + value=500, + step=100, + key=f"word_count_{section}" + ) + with col2: + content_depth = st.selectbox( + "Content Depth", + [depth.value for depth in ContentDepth], + key=f"depth_{section}" + ) + + st.markdown('
', unsafe_allow_html=True) + +def display_section(section: str, subsections: List[str], content: Optional[Dict] = None, generator: Optional[BlogOutlineGenerator] = None): + """Display a section with its content and subsections.""" + st.markdown(f""" +
+

{section}

+ """, unsafe_allow_html=True) + + # Section editing controls + col1, col2 = st.columns([4, 1]) + with col1: + st.markdown(f"### {section}") + with col2: + edit_mode = st.checkbox("Edit Mode", key=f"edit_mode_{section}") + + if content: + # Display content with word count + word_count = len(content.content.split()) + st.markdown(f""" +
+

Content Preview ({word_count} words)

+ {content.content[:500]}... +
+ """, unsafe_allow_html=True) + + # Display image if available + if content.image_path: + st.markdown('
', unsafe_allow_html=True) + st.image(content.image_path, caption=section, use_column_width=True) + st.markdown('
', unsafe_allow_html=True) + + # Display image prompt in expander + if content.image_prompt: + with st.expander("View Image Prompt"): + st.code(content.image_prompt, language="text") + + # Edit mode controls + if edit_mode: + # Edit content + edited_content = edit_section_content(section, content.content) + content.content = edited_content + + # Edit subsections + edited_subsections = edit_subsections(section, subsections) + subsections[:] = edited_subsections + + # Edit metadata + if generator: + edit_section_metadata(section, generator) + + # Display subsections + st.markdown("### Subsections") + st.markdown('
', unsafe_allow_html=True) + for subsection in subsections: + st.markdown(f"- {subsection}") + st.markdown('
', unsafe_allow_html=True) + + st.markdown("
", unsafe_allow_html=True) + +def display_stats(generator, outline): + """Display statistics about the generated outline.""" + total_sections = len(outline) + total_subsections = sum(len(subsections) for subsections in outline.values()) + total_content = sum(len(content.content.split()) for content in generator.section_contents.values()) + + col1, col2, col3 = st.columns(3) + with col1: + st.markdown(f""" +
+

📊 Statistics

+

Total Sections: {total_sections}

+

Total Subsections: {total_subsections}

+

Estimated Word Count: {total_content}

+
+ """, unsafe_allow_html=True) + + with col2: + st.markdown(f""" +
+

🎯 Target

+

Target Word Count: {generator.config.target_word_count}

+

Content Depth: {generator.config.content_depth.value}

+

Style: {generator.config.outline_style.value}

+
+ """, unsafe_allow_html=True) + + with col3: + st.markdown(f""" +
+

📝 Content Type

+

Type: {generator.config.content_type.value}

+

Audience: {generator.config.target_audience}

+

Language: {generator.config.language}

+
+ """, unsafe_allow_html=True) + +def main(): + st.set_page_config( + page_title="Blog Outline Generator", + page_icon="📝", + layout="wide", + initial_sidebar_state="expanded" + ) + + # Header with description + st.title("Blog Outline Generator") + st.markdown(""" + Generate comprehensive blog outlines with AI-powered content and images. + Customize your outline with various options and get detailed content for each section. + """) + + # Sidebar for configuration + with st.sidebar: + st.header("Configuration") + + # Basic settings + topic = st.text_input("Blog Topic", placeholder="Enter your blog topic") + content_type = st.selectbox( + "Content Type", + [type.value for type in ContentType] + ) + content_depth = st.selectbox( + "Content Depth", + [depth.value for depth in ContentDepth] + ) + outline_style = st.selectbox( + "Outline Style", + [style.value for style in OutlineStyle] + ) + + # Content structure + st.subheader("Content Structure") + target_word_count = st.slider("Target Word Count", 500, 5000, 2000, 100) + num_main_sections = st.slider("Number of Main Sections", 3, 10, 5) + num_subsections = st.slider("Subsections per Section", 2, 5, 3) + + # Advanced settings + with st.expander("Advanced Settings"): + include_intro = st.checkbox("Include Introduction", value=True) + include_conclusion = st.checkbox("Include Conclusion", value=True) + include_faqs = st.checkbox("Include FAQs", value=True) + include_resources = st.checkbox("Include Resources", value=True) + + # Image settings + st.subheader("Image Settings") + include_images = st.checkbox("Include Images", value=True) + if include_images: + image_style = st.selectbox( + "Image Style", + ["realistic", "illustration", "minimalist", "photographic", "artistic"] + ) + image_engine = st.selectbox( + "Image Engine", + ["Gemini-AI", "Dalle3", "Stability-AI"] + ) + + # Target audience and language + st.subheader("Target Audience") + target_audience = st.text_input("Target Audience", value="general") + language = st.text_input("Language", value="English") + + # Keywords and exclusions + st.subheader("Content Optimization") + keywords = st.text_area("Keywords (comma-separated)") + exclude_topics = st.text_area("Topics to Exclude (comma-separated)") + + # Main content area + if topic: + # Create configuration + config = OutlineConfig( + content_type=ContentType(content_type), + content_depth=ContentDepth(content_depth), + outline_style=OutlineStyle(outline_style), + target_word_count=target_word_count, + num_main_sections=num_main_sections, + num_subsections_per_section=num_subsections, + include_introduction=include_intro, + include_conclusion=include_conclusion, + include_faqs=include_faqs, + include_resources=include_resources, + include_images=include_images, + image_style=image_style if include_images else "realistic", + image_engine=image_engine if include_images else "Gemini-AI", + target_audience=target_audience, + language=language, + keywords=[k.strip() for k in keywords.split(',')] if keywords else None, + exclude_topics=[t.strip() for t in exclude_topics.split(',')] if exclude_topics else None + ) + + # Initialize generator + generator = BlogOutlineGenerator(config) + + # Generate outline + if st.button("Generate Outline"): + with st.spinner("Generating outline and content..."): + try: + # Add progress bar + progress_bar = st.progress(0) + for i in range(100): + time.sleep(0.01) + progress_bar.progress(i + 1) + + outline = asyncio.run(generator.generate_outline(topic)) + + # Display results + st.success("Outline generated successfully!") + + # Display statistics + display_stats(generator, outline) + + # Output format selection + output_format = st.radio( + "Output Format", + ["Preview", "Markdown", "JSON", "HTML"] + ) + + if output_format == "Preview": + # Display outline with content and images + for section, subsections in outline.items(): + content = generator.section_contents.get(section) + display_section(section, subsections, content) + + elif output_format == "Markdown": + st.code(generator.to_markdown(), language="markdown") + st.download_button( + "Download Markdown", + generator.to_markdown(), + file_name="blog_outline.md", + mime="text/markdown" + ) + + elif output_format == "JSON": + json_output = json.dumps({ + "outline": outline, + "contents": { + section: { + "title": content.title, + "content": content.content, + "image_prompt": content.image_prompt, + "image_path": content.image_path + } + for section, content in generator.section_contents.items() + } + }, indent=2) + st.code(json_output, language="json") + st.download_button( + "Download JSON", + json_output, + file_name="blog_outline.json", + mime="application/json" + ) + + elif output_format == "HTML": + # Add HTML export functionality + html_output = f""" + + + + {topic} - Blog Outline + + + +

{topic}

+ {generator.to_markdown().replace('#', '##')} + + + """ + st.code(html_output, language="html") + st.download_button( + "Download HTML", + html_output, + file_name="blog_outline.html", + mime="text/html" + ) + + except Exception as e: + st.error(f"Error generating outline: {str(e)}") + else: + st.info("Please enter a blog topic to get started.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lib/ai_writers/ai_writer_dashboard.py b/lib/ai_writers/ai_writer_dashboard.py index ba157a9a..36357c19 100644 --- a/lib/ai_writers/ai_writer_dashboard.py +++ b/lib/ai_writers/ai_writer_dashboard.py @@ -8,6 +8,7 @@ from lib.ai_writers.linkedin_writer import LinkedInAIWriter from lib.ai_writers.blog_rewriter_updater.ai_blog_rewriter import write_blog_rewriter from lib.ai_writers.ai_blog_faqs_writer.faqs_ui import main as faqs_generator from lib.ai_writers.ai_blog_writer.ai_blog_generator import ai_blog_writer_page +from lib.ai_writers.ai_outline_writer.outline_ui import main as outline_generator from loguru import logger def list_ai_writers(): @@ -92,6 +93,14 @@ def list_ai_writers(): "category": "Content Creation", "function": faqs_generator, "path": "faqs_generator" + }, + { + "name": "Blog Outline Generator", + "icon": "📋", + "description": "Create detailed blog outlines with AI-powered content generation and image integration", + "category": "Content Creation", + "function": outline_generator, + "path": "outline_generator" } ] diff --git a/lib/blog_sections/get_blog_conclusion.py b/lib/blog_sections/get_blog_conclusion.py deleted file mode 100644 index d2dfed3e..00000000 --- a/lib/blog_sections/get_blog_conclusion.py +++ /dev/null @@ -1,15 +0,0 @@ -def get_blog_conclusion(blog_content): - """ - Accepts a blog content and concludes it. - """ - prompt = f"""As an expert SEO and blog writer, please conclude the given blog providing vital take aways, - summarise key points (no more than 300 characters) in bullet points. The blog content: {blog_content} - """ - logger.info(f"Generating blog conclusion iwth prompt: {prompt}") - try: - # TBD: Add logic for which_provider and which_model - response = openai_chatgpt(prompt) - except Exception as err: - SystemError(f"Error in generating blog conclusion: {err}") - else: - return response diff --git a/lib/blog_sections/get_blog_intro.py b/lib/blog_sections/get_blog_intro.py deleted file mode 100644 index 70572b03..00000000 --- a/lib/blog_sections/get_blog_intro.py +++ /dev/null @@ -1,16 +0,0 @@ -def get_blog_intro(blog_title, blog_topics): - """ - Generate blog introduction as per title and sub topics - """ - prompt = f"""As a skilled wordsmith, I'll equip you with a blog title and relevant topics, tasking you with crafting an engaging introduction. Your challenge: Create a brief, compelling entry that entices readers to explore the entire post. This introduction must be concise (under 250 characters) yet powerful, clearly stating the blog's purpose and what readers stand to gain. Reply with only the introduction. - -Intrigue your audience from the start with vibrant language, employing strong verbs and vivid descriptions. Address a common challenge your readers face, demonstrating empathy and positioning yourself as their go-to expert. Pose thought-provoking questions that prompt reader engagement and contemplation. - -Remember, your words matter. This introduction serves as the cornerstone of the blog post. It should not only captivate attention but also encourage deeper exploration. Additionally, strategically integrate relevant keywords to enhance visibility on search engine results pages (SERPs). Your mission: Craft a blog introduction that resonates, leaving readers eager to delve further into the titled piece: '{blog_title}', covering these sub-topics: {blog_topics}.""" - - try: - # TBD: Add logic for which_provider and which_model - response = openai_chatgpt(prompt) - except Exception as err: - SystemError(f"Error in generating Blog Introduction: {err}") - return response diff --git a/lib/blog_sections/get_blog_outline.py b/lib/blog_sections/get_blog_outline.py deleted file mode 100644 index f65f3a32..00000000 --- a/lib/blog_sections/get_blog_outline.py +++ /dev/null @@ -1,18 +0,0 @@ -def generate_topic_outline(blog_title, num_subtopics): - """ - Given a blog title generate an outline for it - """ - # TBD: Remove hardcoding, make dynamic - prompt = f"""As a SEO expert, suggest only {num_subtopics} beginner-friendly and - insightful sub topics for the blog title: {blog_title}. - Respond with only answer and no description, explanations.""" - - # The suggested {num_subtopics} outline should include few long-tailed keywords and most popular questions. - # TBD: Include --niche - logger.info(f"Prompt used for blog title Outline :\n{prompt}\n") - # TBD: Add logic for which_provider and which_model - try: - response = openai_chatgpt(prompt) - except Exception as err: - SystemError(f"Error in generating Blog Title: {err}") - return response diff --git a/lib/blog_sections/get_blog_topics.py b/lib/blog_sections/get_blog_topics.py deleted file mode 100644 index 2eff26da..00000000 --- a/lib/blog_sections/get_blog_topics.py +++ /dev/null @@ -1,47 +0,0 @@ -def generate_blog_topics(blog_keywords, num_blogs, niche): - """ - For a given prompt, generate blog topics. - Using the davinci-instruct-beta-v3 model. It’s proven to be an ideal - one for generating unique blog content. - Ex: Generate SEO optimized blog topics on given keywords - """ - prompt = f"""As an SEO specialist and blog writer, write {num_blogs} catchy - and SEO-friendly blog topics on {blog_keywords}. The blog title must be less than 80 characters. - The blog titles must follow best SEO practises, be engaging and invite/tempt users to read full blog. - Do not include descriptions, explanations. Do not number the result.""" - - # Beware of keywords stuffing, clustering, semantic should help avoid. - if num_blogs > 5: - # Get more keywords, based on user given keywords. - more_keywords = get_related_keywords(num_blogs, blog_keywords, niche) - prompt = prompt + """Use the following keywords wisely, without keyword stuffing: {more_keywords}""" - - logger.info(f"Prompt used for generating blog topics: \n{prompt}\n") - try: - response = openai_chatgpt(prompt) - return response - except Exception as err: - SystemError(f"Error in generating blog topics: {err}") - - -def get_related_keywords(num_blogs, keywords, niche): - """ - Helper function to get more keywords from GPTs. - """ - # Check if niche: use long tailed, else use popular keywords. - if niche: - prompt = (f"Generate a list without description of the top {num_blogs} most popular and semantically" - f"related long-tailed keywords and entities for the topic of {keywords} that are used in" - "high-quality content and relevant to my competitors." - ) - else: - prompt = (f"Generate a list without description of the top {num_blogs} most popular and" - f" semantically related keywords and entities for the topic of {keywords} that are used" - " in high-quality content and relevant to my competitors." - ) - try: - # TBD: Add logic for which_provider and which_model - response = openai_chatgpt(prompt) - return response - except Exception as err: - SystemError(f"Error in getting related keywords.") diff --git a/lib/blog_sections/get_code_examples.py b/lib/blog_sections/get_code_examples.py deleted file mode 100644 index 1fb04eb5..00000000 --- a/lib/blog_sections/get_code_examples.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -At the command line, only need to run once to install the package via pip: -$ pip install google-generativeai -""" -from .gpt_providers.gemini_pro_text import gemini_text_response - - -def gemini_get_code_samples(blog_article): - """ Provide a programming blog and get code exmaples.""" - prompt = f"""As an expert programmer and copywriter, I will provide you with blog article. - Your task is to research and write one code example for the given blog article. - Do not include your explanations in response. - Blog Article: '{blog_article}' """ - try: - code_sample = gemini_text_response(prompt) - response = combine_blog_code_sample(blog_article, code_sample) - return response - except Exception as err: - raise ValueError(f"Failed to get response from Gemini pro: {err}") - - -def combine_blog_code_sample(blog_article, code_sample): - """ Include the code sample into the given blog. """ - prompt = """You are expert document editor, I will provide you blog article and a code sample. - Your task is to edit the given blog article to include the code sample after the introduction section. - Do not modify the content of the given blog article. Your response should include the whole blog_article with - the code sample added to it. - Adopt the formatting of the given blog article. Do not include explanations of your response. - Edit the given blog to include the code sample in it. - Blog Article: {blog_article}\n - Code sample: {code_sample}\n""" - - try: - response = gemini_text_response(prompt) - return response - except Exception as err: - raise ValueError(f"Failed to combine blog and code: {err}") diff --git a/lib/blog_sections/get_topic_content.py b/lib/blog_sections/get_topic_content.py deleted file mode 100644 index 3d6fcabb..00000000 --- a/lib/blog_sections/get_topic_content.py +++ /dev/null @@ -1,19 +0,0 @@ -def generate_topic_content(blog_keywords, sub_topic): - """ - For each of given topic generate content for it. - """ - # The outline should contain various subheadings and include the starting sentence for each section. - # TBD: Depending on the usecase 'Voice and style' will change to professional etc. - prompt = f"""As a professional blogger and topic authority on {blog_keywords}, - craft factual (no more than 200 characters) subtopic content on {sub_topic}. - Your response should reflect Experience, Expertise, Authoritativeness and Trustworthiness from content. - Voice and style guide: Write in a professional manner, giving enlightening details and reasons. - Use natural language and phrases that a real person would use: in normal conversations. - Format your response using markdown. REMEMBER Not to include introduction or conclusion in your response. - Use headings(h3 to h6 only), subheadings, bullet points, and bold to organize the information.""" - logger.info(f"Generate topic content using prompt:\n{prompt}\n") - try: - response = openai_chatgpt(prompt) - return response - except Exception as err: - SystemError(f"Error in generating topic content: {err}") diff --git a/lib/integrations/wix/README.md b/lib/integrations/wix/README.md new file mode 100644 index 00000000..c569fdf3 --- /dev/null +++ b/lib/integrations/wix/README.md @@ -0,0 +1,208 @@ +# Wix Blog Integration for Alwrity + +This integration allows you to publish blog content from Alwrity directly to your Wix site using the Wix REST API. + +## Features + +- **Blog Post Management**: Create, update, and delete blog posts +- **Media Management**: Upload images and other media files +- **SEO Optimization**: Comprehensive SEO settings and analysis +- **Category Management**: Create and manage blog categories +- **Markdown Support**: Write in markdown and publish as HTML +- **Streamlit UI**: User-friendly interface for publishing + +## Prerequisites + +Before using this integration, you'll need: + +1. A Wix site with the Blog feature enabled +2. Wix API credentials (refresh token and site ID) +3. Python 3.7+ with required dependencies + +## Getting Wix API Credentials + +To use this integration, you need to obtain a refresh token and site ID from Wix: + +1. **Create a Wix Developer Account**: +- Go to [Wix Developers](https://dev.wix.com/) and sign up or log in +- Create a new OAuth app + +2. **Configure OAuth App**: +- Set a name and description for your app +- Add redirect URLs (e.g., `https://localhost:3000/oauth/callback`) +- Save the app and note the App ID and App Secret + +3. **Get a Refresh Token**: +- Follow the OAuth flow to get an authorization code +- Exchange the code for an access token and refresh token +- Detailed instructions: [Wix OAuth Documentation](https://dev.wix.com/api/rest/getting-started/authentication) + +4. **Get Your Site ID**: +- Log in to your Wix account +- Go to your site's dashboard +- The site ID is in the URL: `https://manage.wix.com/dashboard/{SITE_ID}/home` + +## Installation + +The Wix integration is included with Alwrity. No additional installation is required. + +## Usage + +### Using the Streamlit UI + +1. Navigate to the Wix integration in the Alwrity UI +2. Enter your Wix refresh token and site ID +3. Fill in the blog details and content +4. Click "Publish to Wix" + +### Using the Python API + +```python +from lib.integrations.wix_integration import WixIntegration + +# Initialize the integration +wix = WixIntegration( +refresh_token="YOUR_REFRESH_TOKEN", +site_id="YOUR_SITE_ID" +) + +# Publish a blog post +result = wix.publish_blog_post( +title="My Blog Post", +content="# Hello World\n\nThis is my blog post.", +is_markdown=True, +tags=["example", "blog"], +categories=["Technology"], +publish=True +) + +# Get the published post URL +post_url = result.get("post", {}).get("url") +print(f"Published at: {post_url}") +``` + +### Using the Command-Line Interface + +```bash +# Set environment variables +export WIX_REFRESH_TOKEN="YOUR_REFRESH_TOKEN" +export WIX_SITE_ID="YOUR_SITE_ID" + +# List blog posts +python -m lib.integrations.wix_cli list-posts + +# Publish a blog post +python -m lib.integrations.wix_cli publish-post \ +--title "My Blog Post" \ +--content-file blog.md \ +--is-markdown \ +--tags "example,blog" \ +--categories "Technology" + +# Generate an SEO report +python -m lib.integrations.wix_cli seo-report \ +--title "My Blog Post" \ +--keywords "example,blog,technology" +``` + +## API Reference + +### WixIntegration + +The main integration class that provides high-level methods for working with Wix blogs. + +#### Methods + +- `publish_blog_post(title, content, ...)`: Publish a blog post +- `upload_media(file_path, ...)`: Upload a media file +- `get_seo_report(post_id, target_keywords)`: Generate an SEO report +- `list_blog_posts(limit, offset, ...)`: List blog posts +- `list_categories()`: List blog categories +- `create_category(name, description)`: Create a blog category +- `get_post_by_id(post_id)`: Get a blog post by ID +- `get_post_by_title(title)`: Get a blog post by title +- `delete_post(post_id)`: Delete a blog post + +### WixAPIClient + +Low-level client for interacting with the Wix API. + +### WixBlogManager + +Handles blog content management, including markdown processing and image handling. + +### WixSEOOptimizer + +Provides SEO analysis and optimization for blog posts. + +## Error Handling + +The integration includes comprehensive error handling: + +- API errors are logged with detailed information +- Authentication errors provide clear guidance +- File handling errors include path information +- Network errors include retry logic + +## Best Practices + +1. **Store credentials securely**: +- Use environment variables or a secure credential store +- Don't hardcode credentials in your code + +2. **Optimize images before upload**: +- Compress images to reduce file size +- Use appropriate image formats (JPEG for photos, PNG for graphics) + +3. **SEO optimization**: +- Use the SEO report to improve your content +- Include relevant keywords in titles and headings +- Add alt text to all images + +4. **Content management**: +- Use categories and tags consistently +- Include featured images for better visual appeal +- Write clear, concise meta descriptions + +## Troubleshooting + +### Common Issues + +1. **Authentication Errors**: +- Ensure your refresh token is valid +- Check that your site ID is correct +- Verify that your app has the necessary permissions + +2. **API Rate Limits**: +- The Wix API has rate limits that may affect bulk operations +- Add delays between requests if you're publishing many posts + +3. **Image Upload Issues**: +- Check that the image file exists and is readable +- Verify that the image format is supported (JPEG, PNG, GIF) +- Ensure the image file size is within Wix limits + +4. **Content Formatting Issues**: +- If using markdown, ensure it's valid +- Check for special characters that might cause issues +- Verify that HTML content is properly formatted + +### Getting Help + +If you encounter issues not covered here: + +1. Check the logs for detailed error messages +2. Consult the [Wix API Documentation](https://dev.wix.com/api/rest/getting-started) +3. Contact Alwrity support for assistance + +## License + +This integration is part of the Alwrity platform and is subject to the same license terms. + +## Acknowledgements + +- [Wix REST API](https://dev.wix.com/api/rest) for providing the API endpoints +- [Requests](https://docs.python-requests.org/) for HTTP functionality +- [Markdown](https://python-markdown.github.io/) for markdown processing +- [BeautifulSoup](https://www.crummy.com/software/BeautifulSoup/) for HTML parsing +- [Streamlit](https://streamlit.io/) for the user interface diff --git a/lib/integrations/wix/wix_api_client.py b/lib/integrations/wix/wix_api_client.py new file mode 100644 index 00000000..c16eaf83 --- /dev/null +++ b/lib/integrations/wix/wix_api_client.py @@ -0,0 +1,850 @@ +""" +Wix API Client for Blog Management + +This module provides a comprehensive client for interacting with the Wix API +to manage blog posts, SEO settings, and media uploads. + +Documentation: https://dev.wix.com/api/rest/getting-started +""" + +import os +import json +import time +import logging +import requests +from typing import Dict, List, Optional, Union, Any, Tuple +from datetime import datetime +import mimetypes +from pathlib import Path + +# Configure logging +logging.basicConfig( +level=logging.INFO, +format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('wix_api_client') + +class WixAPIClient: +""" +Client for interacting with the Wix API for blog management. + +This client handles authentication, blog post creation/updating, +media uploads, and SEO settings. +""" + +# Base URLs for different Wix API endpoints +BASE_URL = "https://www.wixapis.com" +OAUTH_URL = "https://www.wix.com/oauth" + +# API Endpoints +BLOG_API = "/blog/v3" +MEDIA_API = "/site-media/v1" +SEO_API = "/site-properties/v4/seo" + +def __init__( +self, +api_key: Optional[str] = None, +refresh_token: Optional[str] = None, +site_id: Optional[str] = None +): +""" +Initialize the Wix API Client. + +Args: +api_key: Wix API key (optional if using refresh token) +refresh_token: Wix refresh token for OAuth authentication +site_id: Wix site ID +""" +self.api_key = api_key or os.environ.get('WIX_API_KEY') +self.refresh_token = refresh_token or os.environ.get('WIX_REFRESH_TOKEN') +self.site_id = site_id or os.environ.get('WIX_SITE_ID') +self.access_token = None +self.token_expiry = 0 + +if not self.refresh_token: +logger.warning("No refresh token provided. Authentication will fail.") + +if not self.site_id: +logger.warning("No site ID provided. API calls will fail.") + +def _get_headers(self) -> Dict[str, str]: +""" +Get the headers required for API requests. + +Returns: +Dict containing the necessary headers for Wix API requests +""" +# Ensure we have a valid access token +self._ensure_valid_token() + +headers = { +"Authorization": f"Bearer {self.access_token}", +"wix-site-id": self.site_id, +"Content-Type": "application/json" +} + +return headers + +def _ensure_valid_token(self) -> None: +""" +Ensure we have a valid access token, refreshing if necessary. +""" +current_time = time.time() + +# If token is expired or doesn't exist, refresh it +if not self.access_token or current_time >= self.token_expiry: +self._refresh_access_token() + +def _refresh_access_token(self) -> None: +""" +Refresh the access token using the refresh token. +""" +if not self.refresh_token: +raise ValueError("Refresh token is required for authentication") + +url = f"{self.OAUTH_URL}/access" +payload = { +"grant_type": "refresh_token", +"refresh_token": self.refresh_token, +"client_id": self.api_key if self.api_key else "" +} + +try: +response = requests.post(url, json=payload) +response.raise_for_status() + +data = response.json() +self.access_token = data.get("access_token") + +# Set token expiry (subtract 5 minutes for safety margin) +expires_in = data.get("expires_in", 3600) # Default to 1 hour if not specified +self.token_expiry = time.time() + expires_in - 300 + +logger.info("Successfully refreshed access token") +except requests.exceptions.RequestException as e: +logger.error(f"Failed to refresh access token: {str(e)}") +if response.text: +logger.error(f"Response: {response.text}") +raise + +def _make_request( +self, +method: str, +endpoint: str, +data: Optional[Dict] = None, +params: Optional[Dict] = None, +files: Optional[Dict] = None +) -> Dict: +""" +Make a request to the Wix API. + +Args: +method: HTTP method (GET, POST, PUT, DELETE) +endpoint: API endpoint +data: Request payload +params: Query parameters +files: Files to upload + +Returns: +Response data as dictionary +""" +url = f"{self.BASE_URL}{endpoint}" +headers = self._get_headers() + +# If we're uploading files, remove the Content-Type header +if files: +headers.pop("Content-Type", None) + +try: +response = requests.request( +method=method, +url=url, +headers=headers, +json=data, +params=params, +files=files +) + +# Log request details for debugging +logger.debug(f"Request: {method} {url}") +logger.debug(f"Headers: {headers}") +if data: +logger.debug(f"Data: {json.dumps(data)}") +if params: +logger.debug(f"Params: {params}") + +# Handle response +response.raise_for_status() + +if response.content: +return response.json() +return {} + +except requests.exceptions.HTTPError as e: +logger.error(f"HTTP error: {str(e)}") +if response.text: +logger.error(f"Response: {response.text}") +raise +except requests.exceptions.RequestException as e: +logger.error(f"Request error: {str(e)}") +raise + +# Blog Post Management + +def list_posts( +self, +limit: int = 50, +offset: int = 0, +sort_field: str = "lastPublishedDate", +sort_order: str = "desc", +filter_by: Optional[Dict] = None +) -> Dict: +""" +List blog posts with pagination and sorting. + +Args: +limit: Maximum number of posts to return (default: 50) +offset: Pagination offset (default: 0) +sort_field: Field to sort by (default: lastPublishedDate) +sort_order: Sort order, 'asc' or 'desc' (default: desc) +filter_by: Optional filter criteria + +Returns: +Dictionary containing blog posts and pagination info +""" +endpoint = f"{self.BLOG_API}/posts/query" + +payload = { +"limit": limit, +"offset": offset, +"sort": [ +{ +"fieldName": sort_field, +"order": sort_order +} +] +} + +if filter_by: +payload["filter"] = filter_by + +return self._make_request("POST", endpoint, data=payload) + +def get_post(self, post_id: str) -> Dict: +""" +Get a specific blog post by ID. + +Args: +post_id: ID of the blog post + +Returns: +Blog post data +""" +endpoint = f"{self.BLOG_API}/posts/{post_id}" +return self._make_request("GET", endpoint) + +def create_post( +self, +title: str, +content: str, +excerpt: Optional[str] = None, +featured_image_id: Optional[str] = None, +tags: Optional[List[str]] = None, +categories: Optional[List[str]] = None, +seo_data: Optional[Dict] = None, +publish: bool = False +) -> Dict: +""" +Create a new blog post. + +Args: +title: Post title +content: Post content (HTML) +excerpt: Post excerpt/summary +featured_image_id: ID of the featured image (from media manager) +tags: List of tags +categories: List of category IDs +seo_data: SEO settings for the post +publish: Whether to publish the post immediately + +Returns: +Created blog post data +""" +endpoint = f"{self.BLOG_API}/posts" + +# Prepare the post data +post_data = { +"post": { +"title": title, +"content": content, +"excerpt": excerpt or "", +"featured_image_id": featured_image_id, +"tags": tags or [], +"categoryIds": categories or [] +} +} + +# Add SEO data if provided +if seo_data: +post_data["post"]["seoData"] = seo_data + +# Create the post +response = self._make_request("POST", endpoint, data=post_data) + +# Publish the post if requested +if publish and response.get("post", {}).get("id"): +post_id = response["post"]["id"] +self.publish_post(post_id) +# Refresh the post data to get the published version +response = self.get_post(post_id) + +return response + +def update_post( +self, +post_id: str, +title: Optional[str] = None, +content: Optional[str] = None, +excerpt: Optional[str] = None, +featured_image_id: Optional[str] = None, +tags: Optional[List[str]] = None, +categories: Optional[List[str]] = None, +seo_data: Optional[Dict] = None, +publish: bool = False +) -> Dict: +""" +Update an existing blog post. + +Args: +post_id: ID of the post to update +title: New post title (optional) +content: New post content (HTML) (optional) +excerpt: New post excerpt/summary (optional) +featured_image_id: New featured image ID (optional) +tags: New list of tags (optional) +categories: New list of category IDs (optional) +seo_data: New SEO settings (optional) +publish: Whether to publish the post after updating + +Returns: +Updated blog post data +""" +# First, get the current post data +current_post = self.get_post(post_id) + +if "post" not in current_post: +raise ValueError(f"Post with ID {post_id} not found") + +current_post_data = current_post["post"] + +# Update only the fields that were provided +update_data = { +"post": { +"id": post_id, +"title": title if title is not None else current_post_data.get("title", ""), +"content": content if content is not None else current_post_data.get("content", ""), +"excerpt": excerpt if excerpt is not None else current_post_data.get("excerpt", ""), +"featured_image_id": featured_image_id if featured_image_id is not None else current_post_data.get("featuredImageId"), +"tags": tags if tags is not None else current_post_data.get("tags", []), +"categoryIds": categories if categories is not None else current_post_data.get("categoryIds", []) +} +} + +# Add SEO data if provided +if seo_data: +update_data["post"]["seoData"] = seo_data +elif "seoData" in current_post_data: +update_data["post"]["seoData"] = current_post_data["seoData"] + +# Update the post +endpoint = f"{self.BLOG_API}/posts/{post_id}" +response = self._make_request("PATCH", endpoint, data=update_data) + +# Publish the post if requested +if publish: +self.publish_post(post_id) +# Refresh the post data to get the published version +response = self.get_post(post_id) + +return response + +def delete_post(self, post_id: str) -> Dict: +""" +Delete a blog post. + +Args: +post_id: ID of the post to delete + +Returns: +Response data +""" +endpoint = f"{self.BLOG_API}/posts/{post_id}" +return self._make_request("DELETE", endpoint) + +def publish_post(self, post_id: str) -> Dict: +""" +Publish a draft blog post. + +Args: +post_id: ID of the post to publish + +Returns: +Published post data +""" +endpoint = f"{self.BLOG_API}/posts/{post_id}/publish" +return self._make_request("POST", endpoint) + +def unpublish_post(self, post_id: str) -> Dict: +""" +Unpublish a published blog post (revert to draft). + +Args: +post_id: ID of the post to unpublish + +Returns: +Unpublished post data +""" +endpoint = f"{self.BLOG_API}/posts/{post_id}/unpublish" +return self._make_request("POST", endpoint) + +# Category Management + +def list_categories(self) -> Dict: +""" +List all blog categories. + +Returns: +Dictionary containing blog categories +""" +endpoint = f"{self.BLOG_API}/categories" +return self._make_request("GET", endpoint) + +def create_category(self, label: str, description: Optional[str] = None) -> Dict: +""" +Create a new blog category. + +Args: +label: Category name +description: Category description (optional) + +Returns: +Created category data +""" +endpoint = f"{self.BLOG_API}/categories" + +payload = { +"category": { +"label": label, +"description": description or "" +} +} + +return self._make_request("POST", endpoint, data=payload) + +def update_category( +self, +category_id: str, +label: Optional[str] = None, +description: Optional[str] = None +) -> Dict: +""" +Update an existing blog category. + +Args: +category_id: ID of the category to update +label: New category name (optional) +description: New category description (optional) + +Returns: +Updated category data +""" +# First, get the current category data +current_categories = self.list_categories() + +current_category = None +for category in current_categories.get("categories", []): +if category.get("id") == category_id: +current_category = category +break + +if not current_category: +raise ValueError(f"Category with ID {category_id} not found") + +# Update only the fields that were provided +update_data = { +"category": { +"id": category_id, +"label": label if label is not None else current_category.get("label", ""), +"description": description if description is not None else current_category.get("description", "") +} +} + +endpoint = f"{self.BLOG_API}/categories/{category_id}" +return self._make_request("PATCH", endpoint, data=update_data) + +def delete_category(self, category_id: str) -> Dict: +""" +Delete a blog category. + +Args: +category_id: ID of the category to delete + +Returns: +Response data +""" +endpoint = f"{self.BLOG_API}/categories/{category_id}" +return self._make_request("DELETE", endpoint) + +# Media Management + +def upload_image( +self, +file_path: str, +title: Optional[str] = None, +alt_text: Optional[str] = None, +description: Optional[str] = None +) -> Dict: +""" +Upload an image to the Wix media manager. + +Args: +file_path: Path to the image file +title: Image title (optional) +alt_text: Image alt text for accessibility (optional) +description: Image description (optional) + +Returns: +Uploaded image data +""" +# Check if file exists +if not os.path.isfile(file_path): +raise FileNotFoundError(f"File not found: {file_path}") + +# Get file name and mime type +file_name = os.path.basename(file_path) +mime_type, _ = mimetypes.guess_type(file_path) + +if not mime_type or not mime_type.startswith('image/'): +raise ValueError(f"File does not appear to be an image: {file_path}") + +# Prepare metadata +metadata = { +"title": title or file_name, +"altText": alt_text or "", +"description": description or "" +} + +# First, get an upload URL +endpoint = f"{self.MEDIA_API}/files/upload/url" +upload_url_response = self._make_request("POST", endpoint, data={ +"mimeType": mime_type, +"fileName": file_name +}) + +if "uploadUrl" not in upload_url_response: +raise ValueError("Failed to get upload URL") + +upload_url = upload_url_response["uploadUrl"] + +# Upload the file to the provided URL +with open(file_path, 'rb') as file: +upload_response = requests.post( +upload_url, +files={'file': (file_name, file, mime_type)}, +headers={"Content-Type": mime_type} +) + +upload_response.raise_for_status() + +# Complete the upload with metadata +endpoint = f"{self.MEDIA_API}/files" +complete_data = { +"uploadToken": upload_url_response.get("uploadToken"), +"mediaOptions": { +"mimeType": mime_type, +"fileName": file_name, +"mediaType": "IMAGE", +"title": metadata["title"], +"description": metadata["description"], +"alt": metadata["altText"] +} +} + +return self._make_request("POST", endpoint, data=complete_data) + +def get_media_item(self, media_id: str) -> Dict: +""" +Get details of a specific media item. + +Args: +media_id: ID of the media item + +Returns: +Media item data +""" +endpoint = f"{self.MEDIA_API}/files/{media_id}" +return self._make_request("GET", endpoint) + +def list_media_items( +self, +media_type: str = "IMAGE", +limit: int = 50, +offset: int = 0 +) -> Dict: +""" +List media items with pagination. + +Args: +media_type: Type of media to list (IMAGE, VIDEO, AUDIO, DOCUMENT) +limit: Maximum number of items to return +offset: Pagination offset + +Returns: +Dictionary containing media items and pagination info +""" +endpoint = f"{self.MEDIA_API}/files/query" + +payload = { +"query": { +"paging": { +"limit": limit, +"offset": offset +}, +"filter": { +"mediaType": media_type +} +} +} + +return self._make_request("POST", endpoint, data=payload) + +def delete_media_item(self, media_id: str) -> Dict: +""" +Delete a media item. + +Args: +media_id: ID of the media item to delete + +Returns: +Response data +""" +endpoint = f"{self.MEDIA_API}/files/{media_id}" +return self._make_request("DELETE", endpoint) + +# SEO Management + +def get_seo_settings(self, page_url: str) -> Dict: +""" +Get SEO settings for a specific page. + +Args: +page_url: URL path of the page (e.g., "/blog/my-post") + +Returns: +SEO settings data +""" +endpoint = f"{self.SEO_API}/sites/{self.site_id}/url/{page_url}" +return self._make_request("GET", endpoint) + +def update_seo_settings( +self, +page_url: str, +title: Optional[str] = None, +description: Optional[str] = None, +keywords: Optional[List[str]] = None, +og_image_url: Optional[str] = None, +structured_data: Optional[Dict] = None, +no_index: Optional[bool] = None +) -> Dict: +""" +Update SEO settings for a specific page. + +Args: +page_url: URL path of the page (e.g., "/blog/my-post") +title: SEO title +description: SEO description +keywords: SEO keywords +og_image_url: Open Graph image URL +structured_data: Structured data (JSON-LD) +no_index: Whether to prevent indexing by search engines + +Returns: +Updated SEO settings data +""" +# First, get current SEO settings +try: +current_settings = self.get_seo_settings(page_url) +except: +# If the page doesn't exist yet, start with empty settings +current_settings = {"tags": {}} + +# Prepare the update data +seo_data = { +"tags": {} +} + +# Update only the fields that were provided +if title is not None: +seo_data["tags"]["title"] = title +elif "title" in current_settings.get("tags", {}): +seo_data["tags"]["title"] = current_settings["tags"]["title"] + +if description is not None: +seo_data["tags"]["description"] = description +elif "description" in current_settings.get("tags", {}): +seo_data["tags"]["description"] = current_settings["tags"]["description"] + +if keywords is not None: +seo_data["tags"]["keywords"] = ", ".join(keywords) +elif "keywords" in current_settings.get("tags", {}): +seo_data["tags"]["keywords"] = current_settings["tags"]["keywords"] + +if og_image_url is not None: +seo_data["tags"]["og:image"] = og_image_url +elif "og:image" in current_settings.get("tags", {}): +seo_data["tags"]["og:image"] = current_settings["tags"]["og:image"] + +if structured_data is not None: +seo_data["tags"]["jsonld"] = json.dumps(structured_data) +elif "jsonld" in current_settings.get("tags", {}): +seo_data["tags"]["jsonld"] = current_settings["tags"]["jsonld"] + +if no_index is not None: +seo_data["tags"]["robots"] = "noindex" if no_index else "index" +elif "robots" in current_settings.get("tags", {}): +seo_data["tags"]["robots"] = current_settings["tags"]["robots"] + +endpoint = f"{self.SEO_API}/sites/{self.site_id}/url/{page_url}" +return self._make_request("PUT", endpoint, data=seo_data) + +# Helper Methods + +def create_blog_post_with_image( +self, +title: str, +content: str, +image_path: Optional[str] = None, +excerpt: Optional[str] = None, +tags: Optional[List[str]] = None, +categories: Optional[List[str]] = None, +seo_title: Optional[str] = None, +seo_description: Optional[str] = None, +seo_keywords: Optional[List[str]] = None, +publish: bool = False +) -> Dict: +""" +Create a blog post with an optional featured image in one operation. + +Args: +title: Post title +content: Post content (HTML) +image_path: Path to featured image (optional) +excerpt: Post excerpt/summary (optional) +tags: List of tags (optional) +categories: List of category IDs (optional) +seo_title: SEO title (optional) +seo_description: SEO description (optional) +seo_keywords: SEO keywords (optional) +publish: Whether to publish the post immediately (optional) + +Returns: +Created blog post data +""" +# Upload image if provided +featured_image_id = None +if image_path and os.path.isfile(image_path): +try: +image_response = self.upload_image( +file_path=image_path, +title=title, +alt_text=title +) +featured_image_id = image_response.get("file", {}).get("id") +logger.info(f"Uploaded image with ID: {featured_image_id}") +except Exception as e: +logger.error(f"Failed to upload image: {str(e)}") + +# Prepare SEO data +seo_data = None +if seo_title or seo_description or seo_keywords: +seo_data = { +"title": seo_title or title, +"description": seo_description or excerpt or "", +"keywords": seo_keywords or tags or [] +} + +# Create the blog post +return self.create_post( +title=title, +content=content, +excerpt=excerpt, +featured_image_id=featured_image_id, +tags=tags, +categories=categories, +seo_data=seo_data, +publish=publish +) + +def get_or_create_category(self, category_name: str) -> str: +""" +Get a category ID by name, creating it if it doesn't exist. + +Args: +category_name: Name of the category + +Returns: +Category ID +""" +# List all categories +categories_response = self.list_categories() +categories = categories_response.get("categories", []) + +# Check if category exists +for category in categories: +if category.get("label", "").lower() == category_name.lower(): +return category.get("id") + +# Create category if it doesn't exist +create_response = self.create_category(label=category_name) +return create_response.get("category", {}).get("id") + +def get_post_by_slug(self, slug: str) -> Optional[Dict]: +""" +Find a post by its slug. + +Args: +slug: Post slug + +Returns: +Post data or None if not found +""" +# List posts with a filter for the slug +filter_by = { +"slug": { +"$eq": slug +} +} + +response = self.list_posts(limit=1, filter_by=filter_by) +posts = response.get("posts", []) + +if posts: +return posts[0] +return None + +def get_post_url(self, post_id: str) -> str: +""" +Get the full URL for a blog post. + +Args: +post_id: ID of the blog post + +Returns: +Full URL to the blog post +""" +post_data = self.get_post(post_id) +slug = post_data.get("post", {}).get("slug", "") + +# Get the blog URL prefix +# This is a simplification - in reality, you might need to get this from site settings +return f"/blog/{slug}" \ No newline at end of file diff --git a/lib/integrations/wix/wix_blog_manager.py b/lib/integrations/wix/wix_blog_manager.py new file mode 100644 index 00000000..91571afb --- /dev/null +++ b/lib/integrations/wix/wix_blog_manager.py @@ -0,0 +1,720 @@ +""" +Wix Blog Manager + +This module provides high-level functions for managing blog content on Wix, +including content creation, SEO optimization, and media management. +""" + +import os +import re +import logging +import tempfile +import requests +from typing import Dict, List, Optional, Union, Any, Tuple +from datetime import datetime +from pathlib import Path +import markdown +import html2text +from bs4 import BeautifulSoup + +from .wix_api_client import WixAPIClient + +# Configure logging +logging.basicConfig( +level=logging.INFO, +format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('wix_blog_manager') + +class WixBlogManager: +""" +High-level manager for Wix blog content. + +This class provides convenient methods for common blog management tasks, +building on the lower-level WixAPIClient. +""" + +def __init__( +self, +api_key: Optional[str] = None, +refresh_token: Optional[str] = None, +site_id: Optional[str] = None +): +""" +Initialize the Wix Blog Manager. + +Args: +api_key: Wix API key (optional if using refresh token) +refresh_token: Wix refresh token for OAuth authentication +site_id: Wix site ID +""" +self.client = WixAPIClient(api_key, refresh_token, site_id) + +def publish_markdown_post( +self, +title: str, +markdown_content: str, +featured_image_path: Optional[str] = None, +featured_image_url: Optional[str] = None, +excerpt: Optional[str] = None, +tags: Optional[List[str]] = None, +categories: Optional[List[str]] = None, +seo_title: Optional[str] = None, +seo_description: Optional[str] = None, +seo_keywords: Optional[List[str]] = None, +publish: bool = False +) -> Dict: +""" +Publish a blog post from markdown content. + +Args: +title: Post title +markdown_content: Post content in markdown format +featured_image_path: Local path to featured image (optional) +featured_image_url: URL of featured image to download (optional) +excerpt: Post excerpt/summary (optional) +tags: List of tags (optional) +categories: List of category names (optional) +seo_title: SEO title (optional) +seo_description: SEO description (optional) +seo_keywords: SEO keywords (optional) +publish: Whether to publish the post immediately (optional) + +Returns: +Published blog post data +""" +# Convert markdown to HTML +html_content = self._markdown_to_html(markdown_content) + +# Process images in the content +html_content, embedded_images = self._process_content_images(html_content) + +# Handle featured image +featured_image_id = None +temp_image_path = None + +if featured_image_url and not featured_image_path: +# Download the image from URL +try: +temp_image_path = self._download_image(featured_image_url) +featured_image_path = temp_image_path +except Exception as e: +logger.error(f"Failed to download featured image: {str(e)}") + +if featured_image_path: +try: +image_response = self.client.upload_image( +file_path=featured_image_path, +title=title, +alt_text=title +) +featured_image_id = image_response.get("file", {}).get("id") +logger.info(f"Uploaded featured image with ID: {featured_image_id}") +except Exception as e: +logger.error(f"Failed to upload featured image: {str(e)}") + +# Clean up temporary file if created +if temp_image_path and os.path.exists(temp_image_path): +try: +os.remove(temp_image_path) +except: +pass + +# Process categories - convert names to IDs +category_ids = [] +if categories: +for category_name in categories: +try: +category_id = self.client.get_or_create_category(category_name) +if category_id: +category_ids.append(category_id) +except Exception as e: +logger.error(f"Failed to process category '{category_name}': {str(e)}") + +# Generate excerpt if not provided +if not excerpt: +excerpt = self._generate_excerpt(markdown_content) + +# Prepare SEO data +seo_data = None +if seo_title or seo_description or seo_keywords: +seo_data = { +"title": seo_title or title, +"description": seo_description or excerpt or "", +"keywords": seo_keywords or tags or [] +} + +# Create the blog post +response = self.client.create_post( +title=title, +content=html_content, +excerpt=excerpt, +featured_image_id=featured_image_id, +tags=tags, +categories=category_ids, +seo_data=seo_data, +publish=publish +) + +# Update SEO settings if the post was published +if publish and response.get("post", {}).get("id"): +post_id = response["post"]["id"] +post_url = self.client.get_post_url(post_id) + +try: +self.client.update_seo_settings( +page_url=post_url, +title=seo_title or title, +description=seo_description or excerpt or "", +keywords=seo_keywords or tags, +og_image_url=featured_image_url +) +except Exception as e: +logger.error(f"Failed to update SEO settings: {str(e)}") + +return response + +def update_markdown_post( +self, +post_id: str, +title: Optional[str] = None, +markdown_content: Optional[str] = None, +featured_image_path: Optional[str] = None, +featured_image_url: Optional[str] = None, +excerpt: Optional[str] = None, +tags: Optional[List[str]] = None, +categories: Optional[List[str]] = None, +seo_title: Optional[str] = None, +seo_description: Optional[str] = None, +seo_keywords: Optional[List[str]] = None, +publish: bool = False +) -> Dict: +""" +Update an existing blog post with markdown content. + +Args: +post_id: ID of the post to update +title: New post title (optional) +markdown_content: New post content in markdown format (optional) +featured_image_path: Local path to new featured image (optional) +featured_image_url: URL of new featured image to download (optional) +excerpt: New post excerpt/summary (optional) +tags: New list of tags (optional) +categories: New list of category names (optional) +seo_title: New SEO title (optional) +seo_description: New SEO description (optional) +seo_keywords: New SEO keywords (optional) +publish: Whether to publish the post after updating (optional) + +Returns: +Updated blog post data +""" +# Get current post data +current_post = self.client.get_post(post_id) +if "post" not in current_post: +raise ValueError(f"Post with ID {post_id} not found") + +# Convert markdown to HTML if provided +html_content = None +if markdown_content: +html_content = self._markdown_to_html(markdown_content) +# Process images in the content +html_content, embedded_images = self._process_content_images(html_content) + +# Handle featured image +featured_image_id = None +temp_image_path = None + +if featured_image_url and not featured_image_path: +# Download the image from URL +try: +temp_image_path = self._download_image(featured_image_url) +featured_image_path = temp_image_path +except Exception as e: +logger.error(f"Failed to download featured image: {str(e)}") + +if featured_image_path: +try: +image_response = self.client.upload_image( +file_path=featured_image_path, +title=title or current_post["post"].get("title", ""), +alt_text=title or current_post["post"].get("title", "") +) +featured_image_id = image_response.get("file", {}).get("id") +logger.info(f"Uploaded featured image with ID: {featured_image_id}") +except Exception as e: +logger.error(f"Failed to upload featured image: {str(e)}") + +# Clean up temporary file if created +if temp_image_path and os.path.exists(temp_image_path): +try: +os.remove(temp_image_path) +except: +pass + +# Process categories - convert names to IDs +category_ids = None +if categories: +category_ids = [] +for category_name in categories: +try: +category_id = self.client.get_or_create_category(category_name) +if category_id: +category_ids.append(category_id) +except Exception as e: +logger.error(f"Failed to process category '{category_name}': {str(e)}") + +# Generate excerpt if not provided but markdown is +if not excerpt and markdown_content: +excerpt = self._generate_excerpt(markdown_content) + +# Prepare SEO data +seo_data = None +if seo_title or seo_description or seo_keywords: +seo_data = { +"title": seo_title or title or current_post["post"].get("title", ""), +"description": seo_description or excerpt or current_post["post"].get("excerpt", ""), +"keywords": seo_keywords or tags or current_post["post"].get("tags", []) +} + +# Update the blog post +response = self.client.update_post( +post_id=post_id, +title=title, +content=html_content, +excerpt=excerpt, +featured_image_id=featured_image_id, +tags=tags, +categories=category_ids, +seo_data=seo_data, +publish=publish +) + +# Update SEO settings if needed +if (seo_title or seo_description or seo_keywords or featured_image_url): +post_url = self.client.get_post_url(post_id) + +try: +self.client.update_seo_settings( +page_url=post_url, +title=seo_title or title, +description=seo_description or excerpt, +keywords=seo_keywords or tags, +og_image_url=featured_image_url +) +except Exception as e: +logger.error(f"Failed to update SEO settings: {str(e)}") + +return response + +def find_post_by_title(self, title: str) -> Optional[Dict]: +""" +Find a post by its title (exact match). + +Args: +title: Post title to search for + +Returns: +Post data or None if not found +""" +# List all posts (this is inefficient but Wix API doesn't support filtering by title) +# In a production environment, you might want to implement pagination +response = self.client.list_posts(limit=100) +posts = response.get("posts", []) + +for post in posts: +if post.get("title") == title: +return post + +return None + +def publish_or_update_markdown_post( +self, +title: str, +markdown_content: str, +featured_image_path: Optional[str] = None, +featured_image_url: Optional[str] = None, +excerpt: Optional[str] = None, +tags: Optional[List[str]] = None, +categories: Optional[List[str]] = None, +seo_title: Optional[str] = None, +seo_description: Optional[str] = None, +seo_keywords: Optional[List[str]] = None, +publish: bool = False, +update_if_exists: bool = True +) -> Dict: +""" +Publish a new post or update an existing one with the same title. + +Args: +title: Post title +markdown_content: Post content in markdown format +featured_image_path: Local path to featured image (optional) +featured_image_url: URL of featured image to download (optional) +excerpt: Post excerpt/summary (optional) +tags: List of tags (optional) +categories: List of category names (optional) +seo_title: SEO title (optional) +seo_description: SEO description (optional) +seo_keywords: SEO keywords (optional) +publish: Whether to publish the post immediately (optional) +update_if_exists: Whether to update an existing post with the same title (optional) + +Returns: +Published or updated blog post data +""" +# Check if a post with this title already exists +existing_post = self.find_post_by_title(title) + +if existing_post and update_if_exists: +# Update existing post +logger.info(f"Updating existing post with title: {title}") +return self.update_markdown_post( +post_id=existing_post["id"], +title=title, +markdown_content=markdown_content, +featured_image_path=featured_image_path, +featured_image_url=featured_image_url, +excerpt=excerpt, +tags=tags, +categories=categories, +seo_title=seo_title, +seo_description=seo_description, +seo_keywords=seo_keywords, +publish=publish +) +else: +# Create new post +logger.info(f"Creating new post with title: {title}") +return self.publish_markdown_post( +title=title, +markdown_content=markdown_content, +featured_image_path=featured_image_path, +featured_image_url=featured_image_url, +excerpt=excerpt, +tags=tags, +categories=categories, +seo_title=seo_title, +seo_description=seo_description, +seo_keywords=seo_keywords, +publish=publish +) + +def optimize_seo_for_post( +self, +post_id: str, +seo_title: Optional[str] = None, +seo_description: Optional[str] = None, +seo_keywords: Optional[List[str]] = None, +og_image_url: Optional[str] = None, +structured_data: Optional[Dict] = None +) -> Dict: +""" +Optimize SEO settings for an existing blog post. + +Args: +post_id: ID of the blog post +seo_title: SEO title (optional) +seo_description: SEO description (optional) +seo_keywords: SEO keywords (optional) +og_image_url: Open Graph image URL (optional) +structured_data: Structured data (JSON-LD) (optional) + +Returns: +Updated SEO settings data +""" +# Get the post URL +post_url = self.client.get_post_url(post_id) + +# Update SEO settings +return self.client.update_seo_settings( +page_url=post_url, +title=seo_title, +description=seo_description, +keywords=seo_keywords, +og_image_url=og_image_url, +structured_data=structured_data +) + +def generate_structured_data( +self, +post_id: str, +author_name: str, +publisher_name: str, +publisher_logo_url: str +) -> Dict: +""" +Generate structured data (JSON-LD) for a blog post. + +Args: +post_id: ID of the blog post +author_name: Name of the author +publisher_name: Name of the publisher +publisher_logo_url: URL of the publisher's logo + +Returns: +Structured data as a dictionary +""" +# Get post data +post_data = self.client.get_post(post_id) +post = post_data.get("post", {}) + +# Get post URL +post_url = self.client.get_post_url(post_id) + +# Create structured data +structured_data = { +"@context": "https://schema.org", +"@type": "BlogPosting", +"headline": post.get("title", ""), +"description": post.get("excerpt", ""), +"author": { +"@type": "Person", +"name": author_name +}, +"publisher": { +"@type": "Organization", +"name": publisher_name, +"logo": { +"@type": "ImageObject", +"url": publisher_logo_url +} +}, +"datePublished": post.get("publishedDate", ""), +"dateModified": post.get("lastPublishedDate", "") +} + +# Add featured image if available +if post.get("featuredImageId"): +try: +media_item = self.client.get_media_item(post["featuredImageId"]) +image_url = media_item.get("file", {}).get("url", "") +if image_url: +structured_data["image"] = image_url +except: +pass + +return structured_data + +def apply_structured_data_to_post( +self, +post_id: str, +author_name: str, +publisher_name: str, +publisher_logo_url: str +) -> Dict: +""" +Generate and apply structured data to a blog post. + +Args: +post_id: ID of the blog post +author_name: Name of the author +publisher_name: Name of the publisher +publisher_logo_url: URL of the publisher's logo + +Returns: +Updated SEO settings data +""" +# Generate structured data +structured_data = self.generate_structured_data( +post_id=post_id, +author_name=author_name, +publisher_name=publisher_name, +publisher_logo_url=publisher_logo_url +) + +# Get the post URL +post_url = self.client.get_post_url(post_id) + +# Update SEO settings with structured data +return self.client.update_seo_settings( +page_url=post_url, +structured_data=structured_data +) + +# Helper methods + +def _markdown_to_html(self, markdown_content: str) -> str: +""" +Convert markdown content to HTML. + +Args: +markdown_content: Content in markdown format + +Returns: +HTML content +""" +# Use the markdown library to convert to HTML +html = markdown.markdown( +markdown_content, +extensions=['extra', 'codehilite', 'tables', 'toc'] +) + +return html + +def _html_to_markdown(self, html_content: str) -> str: +""" +Convert HTML content to markdown. + +Args: +html_content: Content in HTML format + +Returns: +Markdown content +""" +# Use html2text to convert HTML to markdown +h = html2text.HTML2Text() +h.ignore_links = False +h.ignore_images = False +h.ignore_tables = False +h.ignore_emphasis = False + +return h.handle(html_content) + +def _process_content_images(self, html_content: str) -> Tuple[str, List[Dict]]: +""" +Process images in HTML content, uploading them to Wix and replacing URLs. + +Args: +html_content: HTML content with image tags + +Returns: +Tuple of (updated HTML content, list of uploaded image data) +""" +soup = BeautifulSoup(html_content, 'html.parser') +img_tags = soup.find_all('img') +uploaded_images = [] + +for img in img_tags: +src = img.get('src', '') +alt = img.get('alt', '') + +# Skip images that are already hosted on Wix +if 'wixstatic.com' in src: +continue + +# Handle images with data URLs +if src.startswith('data:image'): +logger.info("Skipping data URL image - not supported in this implementation") +continue + +# Handle remote images +if src.startswith('http://') or src.startswith('https://'): +try: +# Download the image +temp_path = self._download_image(src) + +# Upload to Wix +image_response = self.client.upload_image( +file_path=temp_path, +title=alt or "Blog image", +alt_text=alt or "Blog image" +) + +# Get the new URL +new_url = image_response.get("file", {}).get("url", "") + +if new_url: +# Replace the src attribute +img['src'] = new_url +uploaded_images.append({ +'original_url': src, +'wix_url': new_url, +'wix_id': image_response.get("file", {}).get("id", "") +}) + +# Clean up temp file +if os.path.exists(temp_path): +os.remove(temp_path) + +except Exception as e: +logger.error(f"Failed to process image {src}: {str(e)}") + +# Handle local images (not implemented in this version) +else: +logger.info(f"Skipping local image {src} - not supported in this implementation") + +# Return the updated HTML +return str(soup), uploaded_images + +def _download_image(self, url: str) -> str: +""" +Download an image from a URL to a temporary file. + +Args: +url: URL of the image + +Returns: +Path to the downloaded temporary file +""" +response = requests.get(url, stream=True) +response.raise_for_status() + +# Determine file extension +content_type = response.headers.get('content-type', '') +extension = '.jpg' # Default + +if 'image/jpeg' in content_type: +extension = '.jpg' +elif 'image/png' in content_type: +extension = '.png' +elif 'image/gif' in content_type: +extension = '.gif' +elif 'image/webp' in content_type: +extension = '.webp' + +# Create a temporary file +fd, temp_path = tempfile.mkstemp(suffix=extension) +os.close(fd) + +# Write the image data to the file +with open(temp_path, 'wb') as f: +for chunk in response.iter_content(chunk_size=8192): +f.write(chunk) + +return temp_path + +def _generate_excerpt(self, markdown_content: str, max_length: int = 160) -> str: +""" +Generate an excerpt from markdown content. + +Args: +markdown_content: Content in markdown format +max_length: Maximum length of the excerpt + +Returns: +Generated excerpt +""" +# Convert markdown to plain text +h = html2text.HTML2Text() +h.ignore_links = True +h.ignore_images = True +h.ignore_tables = True +h.ignore_emphasis = True + +# First convert markdown to HTML, then HTML to plain text +html = markdown.markdown(markdown_content) +plain_text = h.handle(html) + +# Clean up the text +plain_text = re.sub(r'\s+', ' ', plain_text).strip() + +# Truncate to max_length +if len(plain_text) <= max_length: +return plain_text + +# Try to truncate at a sentence boundary +sentences = re.split(r'(?<=[.!?])\s+', plain_text) +excerpt = "" + +for sentence in sentences: +if len(excerpt + sentence) <= max_length: +excerpt += sentence + " " +else: +break + +# If we couldn't get a full sentence, just truncate +if not excerpt: +excerpt = plain_text[:max_length-3] + "..." + +return excerpt.strip() diff --git a/lib/integrations/wix/wix_blog_publisher.py b/lib/integrations/wix/wix_blog_publisher.py new file mode 100644 index 00000000..cf0c00a2 --- /dev/null +++ b/lib/integrations/wix/wix_blog_publisher.py @@ -0,0 +1,350 @@ +""" +Wix Blog Publisher for Alwrity + +This module integrates the Wix API with the Alwrity AI Writer platform, +allowing users to publish generated blog content directly to their Wix site. +""" + +import os +import logging +import tempfile +import streamlit as st +from typing import Dict, List, Optional, Union, Any, Tuple +from pathlib import Path + +from .wix_integration import WixIntegration + +# Configure logging +logging.basicConfig( +level=logging.INFO, +format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('wix_blog_publisher') + +def publish_to_wix( +title: str, +content: str, +is_markdown: bool = True, +featured_image_path: Optional[str] = None, +featured_image_url: Optional[str] = None, +excerpt: Optional[str] = None, +tags: Optional[List[str]] = None, +categories: Optional[List[str]] = None, +seo_title: Optional[str] = None, +seo_description: Optional[str] = None, +seo_keywords: Optional[List[str]] = None, +author_name: Optional[str] = None, +publisher_name: Optional[str] = None, +publisher_logo_url: Optional[str] = None, +publish: bool = True, +update_if_exists: bool = True, +api_key: Optional[str] = None, +refresh_token: Optional[str] = None, +site_id: Optional[str] = None +) -> Dict: +""" +Publish a blog post to Wix. + +Args: +title: Post title +content: Post content (markdown or HTML) +is_markdown: Whether the content is in markdown format +featured_image_path: Local path to featured image (optional) +featured_image_url: URL of featured image to download (optional) +excerpt: Post excerpt/summary (optional) +tags: List of tags (optional) +categories: List of category names (optional) +seo_title: SEO title (optional) +seo_description: SEO description (optional) +seo_keywords: SEO keywords (optional) +author_name: Name of the author (optional) +publisher_name: Name of the publisher (optional) +publisher_logo_url: URL of the publisher's logo (optional) +publish: Whether to publish the post immediately (optional) +update_if_exists: Whether to update an existing post with the same title (optional) +api_key: Wix API key (optional if using refresh token) +refresh_token: Wix refresh token for OAuth authentication +site_id: Wix site ID + +Returns: +Published blog post data +""" +# Initialize Wix integration +wix = WixIntegration(api_key, refresh_token, site_id) + +# Publish the blog post +return wix.publish_blog_post( +title=title, +content=content, +is_markdown=is_markdown, +featured_image_path=featured_image_path, +featured_image_url=featured_image_url, +excerpt=excerpt, +tags=tags, +categories=categories, +seo_title=seo_title, +seo_description=seo_description, +seo_keywords=seo_keywords, +author_name=author_name, +publisher_name=publisher_name, +publisher_logo_url=publisher_logo_url, +publish=publish, +update_if_exists=update_if_exists +) + +def wix_blog_publisher_ui(): +""" +Streamlit UI for publishing blog posts to Wix. +""" +st.title("Publish to Wix") +st.write("Publish your blog content directly to your Wix site.") + +# Authentication settings +st.header("Wix Authentication") + +# Check for saved credentials +if "wix_refresh_token" in st.session_state and "wix_site_id" in st.session_state: +st.success("✅ Wix credentials are saved in this session.") +show_saved = st.checkbox("Show saved credentials") +if show_saved: +st.text_input("Refresh Token", value=st.session_state.wix_refresh_token, type="password", disabled=True) +st.text_input("Site ID", value=st.session_state.wix_site_id, disabled=True) + +clear_creds = st.button("Clear saved credentials") +if clear_creds: +if "wix_refresh_token" in st.session_state: +del st.session_state.wix_refresh_token +if "wix_site_id" in st.session_state: +del st.session_state.wix_site_id +st.rerun() +else: +col1, col2 = st.columns(2) + +with col1: +refresh_token = st.text_input("Wix Refresh Token", type="password", help="Your Wix refresh token for API authentication") + +with col2: +site_id = st.text_input("Wix Site ID", help="Your Wix site ID") + +save_creds = st.checkbox("Save credentials for this session", value=True) + +if st.button("Validate Credentials"): +if not refresh_token: +st.error("Refresh token is required.") +return + +if not site_id: +st.error("Site ID is required.") +return + +# Try to initialize Wix integration to validate credentials +try: +wix = WixIntegration(refresh_token=refresh_token, site_id=site_id) +# Test API call +site_info = wix.get_site_info() +if site_info.get("status") == "connected": +st.success(f"✅ Credentials validated successfully! Found {site_info.get('post_count', 0)} posts and {site_info.get('category_count', 0)} categories.") + +# Save credentials if requested +if save_creds: +st.session_state.wix_refresh_token = refresh_token +st.session_state.wix_site_id = site_id +st.rerun() +else: +st.error(f"❌ Failed to validate credentials: {site_info.get('error', 'Unknown error')}") +except Exception as e: +st.error(f"❌ Failed to validate credentials: {str(e)}") +return + +# Blog content section +st.header("Blog Content") + +# Check if we have content in session state (from other parts of the app) +blog_title = st.text_input( +"Blog Title", +value=st.session_state.get("blog_title", ""), +help="The title of your blog post" +) + +content_type = st.radio( +"Content Format", +["Markdown", "HTML"], +horizontal=True, +help="The format of your blog content" +) +is_markdown = content_type == "Markdown" + +blog_content = st.text_area( +"Blog Content", +value=st.session_state.get("blog_content", ""), +height=300, +help="The content of your blog post" +) + +# Featured image +st.subheader("Featured Image") +image_source = st.radio( +"Image Source", +["None", "Upload", "URL"], +horizontal=True, +help="How to provide the featured image" +) + +featured_image_path = None +featured_image_url = None + +if image_source == "Upload": +uploaded_file = st.file_uploader("Upload Featured Image", type=["jpg", "jpeg", "png", "gif"]) +if uploaded_file: +# Save the uploaded file to a temporary location +with tempfile.NamedTemporaryFile(delete=False, suffix=f".{uploaded_file.name.split('.')[-1]}") as tmp: +tmp.write(uploaded_file.getvalue()) +featured_image_path = tmp.name +elif image_source == "URL": +featured_image_url = st.text_input("Featured Image URL", help="URL of the featured image") + +# Blog metadata +st.header("Blog Metadata") + +col1, col2 = st.columns(2) + +with col1: +excerpt = st.text_area( +"Excerpt", +value=st.session_state.get("blog_excerpt", ""), +help="A short summary of your blog post" +) + +tags_input = st.text_input( +"Tags (comma-separated)", +value=", ".join(st.session_state.get("blog_tags", [])) if isinstance(st.session_state.get("blog_tags", []), list) else st.session_state.get("blog_tags", ""), +help="Tags for your blog post, separated by commas" +) +tags = [tag.strip() for tag in tags_input.split(",")] if tags_input else None + +categories_input = st.text_input( +"Categories (comma-separated)", +value=", ".join(st.session_state.get("blog_categories", [])) if isinstance(st.session_state.get("blog_categories", []), list) else st.session_state.get("blog_categories", ""), +help="Categories for your blog post, separated by commas" +) +categories = [cat.strip() for cat in categories_input.split(",")] if categories_input else None + +with col2: +author_name = st.text_input("Author Name", help="Name of the blog post author") +publisher_name = st.text_input("Publisher Name", help="Name of the blog publisher (usually your site name)") +publisher_logo_url = st.text_input("Publisher Logo URL", help="URL of the publisher's logo") + +# SEO settings +with st.expander("SEO Settings"): +seo_title = st.text_input("SEO Title", value=blog_title, help="Title for search engines (defaults to blog title)") +seo_description = st.text_area("SEO Description", value=excerpt, help="Description for search engines (defaults to excerpt)") +seo_keywords_input = st.text_input("SEO Keywords (comma-separated)", value=tags_input, help="Keywords for search engines (defaults to tags)") +seo_keywords = [kw.strip() for kw in seo_keywords_input.split(",")] if seo_keywords_input else None + +# Publishing options +st.header("Publishing Options") + +col1, col2 = st.columns(2) + +with col1: +publish = not st.checkbox("Save as draft", help="If checked, the post will be saved as a draft instead of being published") + +with col2: +update_if_exists = st.checkbox("Update if exists", value=True, help="If checked, an existing post with the same title will be updated") + +# Publish button +if st.button("Publish to Wix", type="primary"): +if not blog_title: +st.error("Blog title is required.") +return + +if not blog_content: +st.error("Blog content is required.") +return + +# Get credentials +refresh_token = st.session_state.get("wix_refresh_token") +site_id = st.session_state.get("wix_site_id") + +if not refresh_token or not site_id: +st.error("Wix credentials are required. Please enter them in the authentication section.") +return + +# Show progress +with st.spinner("Publishing to Wix..."): +try: +# Publish to Wix +result = publish_to_wix( +title=blog_title, +content=blog_content, +is_markdown=is_markdown, +featured_image_path=featured_image_path, +featured_image_url=featured_image_url, +excerpt=excerpt, +tags=tags, +categories=categories, +seo_title=seo_title, +seo_description=seo_description, +seo_keywords=seo_keywords, +author_name=author_name, +publisher_name=publisher_name, +publisher_logo_url=publisher_logo_url, +publish=publish, +update_if_exists=update_if_exists, +refresh_token=refresh_token, +site_id=site_id +) + +# Clean up temporary file if created +if featured_image_path and os.path.exists(featured_image_path) and featured_image_path.startswith(tempfile.gettempdir()): +try: +os.remove(featured_image_path) +except: +pass + +# Show success message +st.success("✅ Blog post published successfully!") + +# Show post details +post = result.get("post", {}) +st.subheader("Published Post Details") + +col1, col2 = st.columns(2) + +with col1: +st.write(f"**Title:** {post.get('title', 'N/A')}") +st.write(f"**Status:** {post.get('status', 'N/A')}") +st.write(f"**ID:** {post.get('id', 'N/A')}") + +with col2: +st.write(f"**Published Date:** {post.get('publishedDate', 'N/A')}") +st.write(f"**URL:** {post.get('url', 'N/A')}") +st.write(f"**Tags:** {', '.join(post.get('tags', []))}") + +# Add a view button if URL is available +if post.get("url"): +st.markdown(f"[View Post]({post.get('url')})") + +# Add SEO report button +if st.button("Generate SEO Report"): +with st.spinner("Generating SEO report..."): +try: +wix = WixIntegration(refresh_token=refresh_token, site_id=site_id) +seo_report = wix.get_seo_report(post.get("id"), seo_keywords or tags or []) + +st.subheader("SEO Report") +st.write(f"**SEO Score:** {seo_report.get('seo_score', 0):.1f}/100") + +st.write("**Recommendations:**") +for i, rec in enumerate(seo_report.get("recommendations", [])): +st.write(f"{i+1}. {rec}") +except Exception as e: +st.error(f"Failed to generate SEO report: {str(e)}") + +except Exception as e: +st.error(f"❌ Failed to publish blog post: {str(e)}") +logger.error(f"Failed to publish blog post: {str(e)}") + +# For testing the UI directly +if __name__ == "__main__": +wix_blog_publisher_ui() \ No newline at end of file diff --git a/lib/integrations/wix/wix_integration.py b/lib/integrations/wix/wix_integration.py new file mode 100644 index 00000000..59f3a2f1 --- /dev/null +++ b/lib/integrations/wix/wix_integration.py @@ -0,0 +1,388 @@ +""" +Wix Integration for Alwrity + +This module provides a high-level interface for integrating Wix blog functionality +with the Alwrity AI Writer platform. +""" + +import os +import logging +import json +from typing import Dict, List, Optional, Union, Any, Tuple +from pathlib import Path + +from .wix_api_client import WixAPIClient +from .wix_blog_manager import WixBlogManager +from .wix_seo_optimizer import WixSEOOptimizer + +# Configure logging +logging.basicConfig( +level=logging.INFO, +format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('wix_integration') + +class WixIntegration: +""" +Main integration class for Wix blog functionality. + +This class provides a simplified interface for common operations, +combining the functionality of the API client, blog manager, and SEO optimizer. +""" + +def __init__( +self, +api_key: Optional[str] = None, +refresh_token: Optional[str] = None, +site_id: Optional[str] = None +): +""" +Initialize the Wix Integration. + +Args: +api_key: Wix API key (optional if using refresh token) +refresh_token: Wix refresh token for OAuth authentication +site_id: Wix site ID +""" +self.api_client = WixAPIClient(api_key, refresh_token, site_id) +self.blog_manager = WixBlogManager(api_key, refresh_token, site_id) +self.seo_optimizer = WixSEOOptimizer(api_key, refresh_token, site_id) + +def publish_blog_post( +self, +title: str, +content: str, +is_markdown: bool = True, +featured_image_path: Optional[str] = None, +featured_image_url: Optional[str] = None, +excerpt: Optional[str] = None, +tags: Optional[List[str]] = None, +categories: Optional[List[str]] = None, +seo_title: Optional[str] = None, +seo_description: Optional[str] = None, +seo_keywords: Optional[List[str]] = None, +author_name: Optional[str] = None, +publisher_name: Optional[str] = None, +publisher_logo_url: Optional[str] = None, +publish: bool = True, +update_if_exists: bool = True +) -> Dict: +""" +Publish a blog post with comprehensive SEO optimization. + +Args: +title: Post title +content: Post content (markdown or HTML) +is_markdown: Whether the content is in markdown format +featured_image_path: Local path to featured image (optional) +featured_image_url: URL of featured image to download (optional) +excerpt: Post excerpt/summary (optional) +tags: List of tags (optional) +categories: List of category names (optional) +seo_title: SEO title (optional) +seo_description: SEO description (optional) +seo_keywords: SEO keywords (optional) +author_name: Name of the author (optional) +publisher_name: Name of the publisher (optional) +publisher_logo_url: URL of the publisher's logo (optional) +publish: Whether to publish the post immediately (optional) +update_if_exists: Whether to update an existing post with the same title (optional) + +Returns: +Published blog post data +""" +# Generate SEO data if not provided +if not seo_keywords and tags: +seo_keywords = tags + +if not seo_title: +seo_title = title + +if not seo_description and not excerpt: +if is_markdown: +# Generate description from markdown content +seo_description = self.blog_manager._generate_excerpt(content) +else: +# Generate description from HTML content +seo_description = self.seo_optimizer.generate_meta_description(content) +elif not seo_description: +seo_description = excerpt + +# Publish or update the post +if is_markdown: +response = self.blog_manager.publish_or_update_markdown_post( +title=title, +markdown_content=content, +featured_image_path=featured_image_path, +featured_image_url=featured_image_url, +excerpt=excerpt, +tags=tags, +categories=categories, +seo_title=seo_title, +seo_description=seo_description, +seo_keywords=seo_keywords, +publish=publish, +update_if_exists=update_if_exists +) +else: +# Find existing post or create new one +existing_post = self.blog_manager.find_post_by_title(title) + +if existing_post and update_if_exists: +# Update existing post +response = self.api_client.update_post( +post_id=existing_post["id"], +title=title, +content=content, +excerpt=excerpt, +tags=tags, +categories=[self.api_client.get_or_create_category(cat) for cat in categories] if categories else None, +seo_data={ +"title": seo_title, +"description": seo_description, +"keywords": seo_keywords or [] +}, +publish=publish +) +else: +# Create new post +response = self.api_client.create_post( +title=title, +content=content, +excerpt=excerpt, +tags=tags, +categories=[self.api_client.get_or_create_category(cat) for cat in categories] if categories else None, +seo_data={ +"title": seo_title, +"description": seo_description, +"keywords": seo_keywords or [] +}, +publish=publish +) + +# Apply additional SEO optimization if the post was published +if publish and response.get("post", {}).get("id"): +post_id = response["post"]["id"] + +# Apply structured data if author and publisher info is provided +if author_name and publisher_name and publisher_logo_url: +try: +self.seo_optimizer.apply_structured_data_to_post( +post_id=post_id, +author_name=author_name, +publisher_name=publisher_name, +publisher_logo_url=publisher_logo_url +) +except Exception as e: +logger.error(f"Failed to apply structured data: {str(e)}") + +# Apply comprehensive SEO optimization +try: +self.seo_optimizer.apply_seo_optimization( +post_id=post_id, +title=seo_title, +description=seo_description, +keywords=seo_keywords, +author_name=author_name, +publisher_name=publisher_name, +publisher_logo_url=publisher_logo_url, +og_image_url=featured_image_url +) +except Exception as e: +logger.error(f"Failed to apply SEO optimization: {str(e)}") + +return response + +def upload_media( +self, +file_path: str, +title: Optional[str] = None, +alt_text: Optional[str] = None, +description: Optional[str] = None +) -> Dict: +""" +Upload a media file to Wix. + +Args: +file_path: Path to the media file +title: Media title (optional) +alt_text: Media alt text (optional) +description: Media description (optional) + +Returns: +Uploaded media data +""" +return self.api_client.upload_image( +file_path=file_path, +title=title, +alt_text=alt_text, +description=description +) + +def get_seo_report(self, post_id: str, target_keywords: List[str]) -> Dict: +""" +Generate a comprehensive SEO report for a blog post. + +Args: +post_id: ID of the blog post +target_keywords: List of target keywords + +Returns: +Dictionary with SEO report data +""" +return self.seo_optimizer.generate_seo_report(post_id, target_keywords) + +def list_blog_posts( +self, +limit: int = 50, +offset: int = 0, +sort_field: str = "lastPublishedDate", +sort_order: str = "desc" +) -> Dict: +""" +List blog posts with pagination and sorting. + +Args: +limit: Maximum number of posts to return (default: 50) +offset: Pagination offset (default: 0) +sort_field: Field to sort by (default: lastPublishedDate) +sort_order: Sort order, 'asc' or 'desc' (default: desc) + +Returns: +Dictionary containing blog posts and pagination info +""" +return self.api_client.list_posts( +limit=limit, +offset=offset, +sort_field=sort_field, +sort_order=sort_order +) + +def list_categories(self) -> Dict: +""" +List all blog categories. + +Returns: +Dictionary containing blog categories +""" +return self.api_client.list_categories() + +def create_category(self, name: str, description: Optional[str] = None) -> str: +""" +Create a new blog category. + +Args: +name: Category name +description: Category description (optional) + +Returns: +ID of the created category +""" +response = self.api_client.create_category( +label=name, +description=description +) +return response.get("category", {}).get("id", "") + +def get_post_by_id(self, post_id: str) -> Dict: +""" +Get a blog post by ID. + +Args: +post_id: ID of the blog post + +Returns: +Blog post data +""" +return self.api_client.get_post(post_id) + +def get_post_by_title(self, title: str) -> Optional[Dict]: +""" +Get a blog post by title. + +Args: +title: Title of the blog post + +Returns: +Blog post data or None if not found +""" +return self.blog_manager.find_post_by_title(title) + +def delete_post(self, post_id: str) -> Dict: +""" +Delete a blog post. + +Args: +post_id: ID of the blog post + +Returns: +Response data +""" +return self.api_client.delete_post(post_id) + +def update_post_status(self, post_id: str, publish: bool = True) -> Dict: +""" +Update the publication status of a blog post. + +Args: +post_id: ID of the blog post +publish: Whether to publish (True) or unpublish (False) the post + +Returns: +Updated blog post data +""" +if publish: +return self.api_client.publish_post(post_id) +else: +return self.api_client.unpublish_post(post_id) + +def search_posts(self, query: str, limit: int = 10) -> List[Dict]: +""" +Search for blog posts by content or title. + +Args: +query: Search query +limit: Maximum number of results to return + +Returns: +List of matching blog posts +""" +# First try to find by title +title_matches = [] +try: +all_posts = self.list_blog_posts(limit=100)["posts"] +for post in all_posts: +if query.lower() in post.get("title", "").lower(): +title_matches.append(post) +if len(title_matches) >= limit: +break +except Exception as e: +logger.error(f"Error searching posts by title: {str(e)}") + +return title_matches[:limit] + +def get_site_info(self) -> Dict: +""" +Get information about the Wix site. + +Returns: +Dictionary with site information +""" +try: +# Make a simple API call to verify credentials and get site info +posts = self.list_blog_posts(limit=1) +categories = self.list_categories() + +return { +"site_id": self.api_client.site_id, +"post_count": posts.get("totalCount", 0), +"category_count": len(categories.get("categories", [])), +"status": "connected" +} +except Exception as e: +logger.error(f"Error getting site info: {str(e)}") +return { +"site_id": self.api_client.site_id, +"status": "error", +"error": str(e) +} \ No newline at end of file diff --git a/lib/integrations/wordpress_blog_uploader.py b/lib/integrations/wordpress/wordpress_blog_uploader.py similarity index 100% rename from lib/integrations/wordpress_blog_uploader.py rename to lib/integrations/wordpress/wordpress_blog_uploader.py diff --git a/lib/utils/seo_tools.py b/lib/utils/seo_tools.py index daa36980..7ca33ab3 100644 --- a/lib/utils/seo_tools.py +++ b/lib/utils/seo_tools.py @@ -8,7 +8,7 @@ from lib.ai_seo_tools.optimize_images_for_upload import main_img_optimizer from lib.ai_seo_tools.google_pagespeed_insights import google_pagespeed_insights from lib.ai_seo_tools.on_page_seo_analyzer import analyze_onpage_seo from lib.ai_seo_tools.weburl_seo_checker import url_seo_checker -from lib.ai_marketing_tools.backlinking_ui_streamlit import backlinking_ui +from lib.ai_marketing_tools.ai_backlinker.backlinking_ui_streamlit import backlinking_ui def ai_seo_tools():