Recovered state: integrated TrendSurferAgent, restored frontend/backend files, and cleaned up recovery scripts
This commit is contained in:
16
.gitignore
vendored
16
.gitignore
vendored
@@ -4,6 +4,22 @@ __pycache__/
|
||||
*.db
|
||||
*.sqlite*
|
||||
|
||||
.trae/
|
||||
.trae
|
||||
|
||||
workspace/
|
||||
workspace/*
|
||||
|
||||
.trae/
|
||||
/backend/database/migrations/*
|
||||
/backend/.db
|
||||
backend/*.db
|
||||
backend\youtube_audio
|
||||
youtube_avatars
|
||||
backend\youtube_images
|
||||
|
||||
backend/.trae_*
|
||||
|
||||
# Onboarding progress files
|
||||
.onboarding_progress.json
|
||||
backend/.onboarding_progress.json
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
|
||||
# AI Backlinking Tool
|
||||
|
||||
## Overview
|
||||
|
||||
The `ai_backlinking.py` module is part of the [AI-Writer](https://github.com/AJaySi/AI-Writer) project. It simplifies and automates the process of finding and securing backlink opportunities. Using AI, the tool performs web research, extracts contact information, and sends personalized outreach emails for guest posting opportunities, making it an essential tool for content writers, digital marketers, and solopreneurs.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
| Feature | Description |
|
||||
|-------------------------------|-----------------------------------------------------------------------------|
|
||||
| **Automated Web Scraping** | Extract guest post opportunities, contact details, and website insights. |
|
||||
| **AI-Powered Emails** | Create personalized outreach emails tailored to target websites. |
|
||||
| **Email Automation** | Integrate with platforms like Gmail or SendGrid for streamlined communication. |
|
||||
| **Lead Management** | Track email status (sent, replied, successful) and follow up efficiently. |
|
||||
| **Batch Processing** | Handle multiple keywords and queries simultaneously. |
|
||||
| **AI-Driven Follow-Up** | Automate polite reminders if there's no response. |
|
||||
| **Reports and Analytics** | View performance metrics like email open rates and backlink success rates. |
|
||||
|
||||
---
|
||||
|
||||
## Workflow Breakdown
|
||||
|
||||
| Step | Action | Example |
|
||||
|-------------------------------|---------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| **Input Keywords** | Provide keywords for backlinking opportunities. | *E.g., "AI tools", "SEO strategies", "content marketing."* |
|
||||
| **Generate Search Queries** | Automatically create queries for search engines. | *E.g., "AI tools + 'write for us'" or "content marketing + 'submit a guest post.'"* |
|
||||
| **Web Scraping** | Collect URLs, email addresses, and content details from target websites. | Extract "editor@contentblog.com" from "https://contentblog.com/write-for-us". |
|
||||
| **Compose Outreach Emails** | Use AI to draft personalized emails based on scraped website data. | Email tailored to "Content Blog" discussing "AI tools for better content writing." |
|
||||
| **Automated Email Sending** | Review and send emails or fully automate the process. | Send emails through Gmail or other SMTP services. |
|
||||
| **Follow-Ups** | Automate follow-ups for non-responsive contacts. | A polite reminder email sent 7 days later. |
|
||||
| **Track and Log Results** | Monitor sent emails, responses, and backlink placements. | View logs showing responses and backlink acquisition rate. |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python Version**: 3.6 or higher.
|
||||
- **Required Packages**: `googlesearch-python`, `loguru`, `smtplib`, `email`.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/AJaySi/AI-Writer.git
|
||||
cd AI-Writer
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Usage
|
||||
|
||||
Here’s a quick example of how to use the tool:
|
||||
|
||||
```python
|
||||
from lib.ai_marketing_tools.ai_backlinking import main_backlinking_workflow
|
||||
|
||||
# Email configurations
|
||||
smtp_config = {
|
||||
'server': 'smtp.gmail.com',
|
||||
'port': 587,
|
||||
'user': 'your_email@gmail.com',
|
||||
'password': 'your_password'
|
||||
}
|
||||
|
||||
imap_config = {
|
||||
'server': 'imap.gmail.com',
|
||||
'user': 'your_email@gmail.com',
|
||||
'password': 'your_password'
|
||||
}
|
||||
|
||||
# Proposal details
|
||||
user_proposal = {
|
||||
'user_name': 'Your Name',
|
||||
'user_email': 'your_email@gmail.com',
|
||||
'topic': 'Proposed guest post topic'
|
||||
}
|
||||
|
||||
# Keywords to search
|
||||
keywords = ['AI tools', 'SEO strategies', 'content marketing']
|
||||
|
||||
# Start the workflow
|
||||
main_backlinking_workflow(keywords, smtp_config, imap_config, user_proposal)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Functions
|
||||
|
||||
| Function | Purpose |
|
||||
|--------------------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| `generate_search_queries(keyword)` | Create search queries to find guest post opportunities. |
|
||||
| `find_backlink_opportunities(keyword)` | Scrape websites for backlink opportunities. |
|
||||
| `compose_personalized_email()` | Draft outreach emails using AI insights and website data. |
|
||||
| `send_email()` | Send emails using SMTP configurations. |
|
||||
| `check_email_responses()` | Monitor inbox for replies using IMAP. |
|
||||
| `send_follow_up_email()` | Automate polite reminders to non-responsive contacts. |
|
||||
| `log_sent_email()` | Keep a record of all sent emails and responses. |
|
||||
| `main_backlinking_workflow()` | Execute the complete backlinking workflow for multiple keywords. |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. For more details, refer to the [LICENSE](LICENSE) file.
|
||||
|
||||
---
|
||||
@@ -1,423 +0,0 @@
|
||||
#Problem:
|
||||
#
|
||||
#Finding websites for guest posts is manual, tedious, and time-consuming. Communicating with webmasters, maintaining conversations, and keeping track of backlinking opportunities is difficult to scale. Content creators and marketers struggle with discovering new websites and consistently getting backlinks.
|
||||
#Solution:
|
||||
#
|
||||
#An AI-powered backlinking app that automates web research, scrapes websites, extracts contact information, and sends personalized outreach emails to webmasters. This would simplify the entire process, allowing marketers to scale their backlinking strategy with minimal manual intervention.
|
||||
#Core Workflow:
|
||||
#
|
||||
# User Input:
|
||||
# Keyword Search: The user inputs a keyword (e.g., "AI writers").
|
||||
# Search Queries: Your app will append various search strings to this keyword to find backlinking opportunities (e.g., "AI writers + 'Write for Us'").
|
||||
#
|
||||
# Web Research:
|
||||
#
|
||||
# Use search engines or web scraping to run multiple queries:
|
||||
# Keyword + "Guest Contributor"
|
||||
# Keyword + "Add Guest Post"
|
||||
# Keyword + "Write for Us", etc.
|
||||
#
|
||||
# Collect URLs of websites that have pages or posts related to guest post opportunities.
|
||||
#
|
||||
# Scrape Website Data:
|
||||
# Contact Information Extraction:
|
||||
# Scrape the website for contact details (email addresses, contact forms, etc.).
|
||||
# Use natural language processing (NLP) to understand the type of content on the website and who the contact person might be (webmaster, editor, or guest post manager).
|
||||
# Website Content Understanding:
|
||||
# Scrape a summary of each website's content (e.g., their blog topics, categories, and tone) to personalize the email based on the site's focus.
|
||||
#
|
||||
# Personalized Outreach:
|
||||
# AI Email Composition:
|
||||
# Compose personalized outreach emails based on:
|
||||
# The scraped data (website content, topic focus, etc.).
|
||||
# The user's input (what kind of guest post or content they want to contribute).
|
||||
# Example: "Hi [Webmaster Name], I noticed that your site [Site Name] features high-quality content about [Topic]. I would love to contribute a guest post on [Proposed Topic] in exchange for a backlink."
|
||||
#
|
||||
# Automated Email Sending:
|
||||
# Review Emails (Optional HITL):
|
||||
# Let users review and approve the personalized emails before they are sent, or allow full automation.
|
||||
# Send Emails:
|
||||
# Automate email dispatch through an integrated SMTP or API (e.g., Gmail API, SendGrid).
|
||||
# Keep track of which emails were sent, bounced, or received replies.
|
||||
#
|
||||
# Scaling the Search:
|
||||
# Repeat for Multiple Keywords:
|
||||
# Run the same scraping and outreach process for a list of relevant keywords, either automatically suggested or uploaded by the user.
|
||||
# Keep Track of Sent Emails:
|
||||
# Maintain a log of all sent emails, responses, and follow-up reminders to avoid repetition or forgotten leads.
|
||||
#
|
||||
# Tracking Responses and Follow-ups:
|
||||
# Automated Responses:
|
||||
# If a website replies positively, AI can respond with predefined follow-up emails (e.g., proposing topics, confirming submission deadlines).
|
||||
# Follow-up Reminders:
|
||||
# If there's no reply, the system can send polite follow-up reminders at pre-set intervals.
|
||||
#
|
||||
#Key Features:
|
||||
#
|
||||
# Automated Web Scraping:
|
||||
# Scrape websites for guest post opportunities using a predefined set of search queries based on user input.
|
||||
# Extract key information like email addresses, names, and submission guidelines.
|
||||
#
|
||||
# Personalized Email Writing:
|
||||
# Leverage AI to create personalized emails using the scraped website information.
|
||||
# Tailor each email to the tone, content style, and focus of the website.
|
||||
#
|
||||
# Email Sending Automation:
|
||||
# Integrate with email platforms (e.g., Gmail, SendGrid, or custom SMTP).
|
||||
# Send automated outreach emails with the ability for users to review first (HITL - Human-in-the-loop) or automate completely.
|
||||
#
|
||||
# Customizable Email Templates:
|
||||
# Allow users to customize or choose from a set of email templates for different types of outreach (e.g., guest post requests, follow-up emails, submission offers).
|
||||
#
|
||||
# Lead Tracking and Management:
|
||||
# Track all emails sent, monitor replies, and keep track of successful backlinks.
|
||||
# Log each lead's status (e.g., emailed, responded, no reply) to manage future interactions.
|
||||
#
|
||||
# Multiple Keywords/Queries:
|
||||
# Allow users to run the same process for a batch of keywords, automatically generating relevant search queries for each.
|
||||
#
|
||||
# AI-Driven Follow-Up:
|
||||
# Schedule follow-up emails if there is no response after a specified period.
|
||||
#
|
||||
# Reports and Analytics:
|
||||
# Provide users with reports on how many emails were sent, opened, replied to, and successful backlink placements.
|
||||
#
|
||||
#Advanced Features (for Scaling and Optimization):
|
||||
#
|
||||
# Domain Authority Filtering:
|
||||
# Use SEO APIs (e.g., Moz, Ahrefs) to filter websites based on their domain authority or backlink strength.
|
||||
# Prioritize high-authority websites to maximize the impact of backlinks.
|
||||
#
|
||||
# Spam Detection:
|
||||
# Use AI to detect and avoid spammy or low-quality websites that might harm the user's SEO.
|
||||
#
|
||||
# Contact Form Auto-Fill:
|
||||
# If the site only offers a contact form (without email), automatically fill and submit the form with AI-generated content.
|
||||
#
|
||||
# Dynamic Content Suggestions:
|
||||
# Suggest guest post topics based on the website's focus, using NLP to analyze the site's existing content.
|
||||
#
|
||||
# Bulk Email Support:
|
||||
# Allow users to bulk-send outreach emails while still personalizing each message for scalability.
|
||||
#
|
||||
# AI Copy Optimization:
|
||||
# Use copywriting AI to optimize email content, adjusting tone and CTA based on the target audience.
|
||||
#
|
||||
#Challenges and Considerations:
|
||||
#
|
||||
# Legal Compliance:
|
||||
# Ensure compliance with anti-spam laws (e.g., CAN-SPAM, GDPR) by including unsubscribe options or manual email approval.
|
||||
#
|
||||
# Scraping Limits:
|
||||
# Be mindful of scraping limits on certain websites and employ smart throttling or use API-based scraping for better reliability.
|
||||
#
|
||||
# Deliverability:
|
||||
# Ensure emails are delivered properly without landing in spam folders by integrating proper email authentication (SPF, DKIM) and using high-reputation SMTP servers.
|
||||
#
|
||||
# Maintaining Email Personalization:
|
||||
# Striking the balance between automating the email process and keeping each message personal enough to avoid being flagged as spam.
|
||||
#
|
||||
#Technology Stack:
|
||||
#
|
||||
# Web Scraping: BeautifulSoup, Scrapy, or Puppeteer for scraping guest post opportunities and contact information.
|
||||
# Email Automation: Integrate with Gmail API, SendGrid, or Mailgun for sending emails.
|
||||
# NLP for Personalization: GPT-based models for email generation and web content understanding.
|
||||
# Frontend: React or Vue for the user interface.
|
||||
# Backend: Python/Node.js with Flask or Express for the API and automation logic.
|
||||
# Database: MongoDB or PostgreSQL to track leads, emails, and responses.
|
||||
#
|
||||
#This solution will significantly streamline the backlinking process by automating the most tedious tasks, from finding sites to personalizing outreach, enabling marketers to focus on content creation and high-level strategies.
|
||||
|
||||
|
||||
import sys
|
||||
# from googlesearch import search # Temporarily disabled for future enhancement
|
||||
from loguru import logger
|
||||
from lib.ai_web_researcher.firecrawl_web_crawler import scrape_website
|
||||
from lib.gpt_providers.text_generation.main_text_generation import llm_text_gen
|
||||
from lib.ai_web_researcher.firecrawl_web_crawler import scrape_url
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
# Configure logger
|
||||
logger.remove()
|
||||
logger.add(sys.stdout,
|
||||
colorize=True,
|
||||
format="<level>{level}</level>|<green>{file}:{line}:{function}</green>| {message}"
|
||||
)
|
||||
|
||||
def generate_search_queries(keyword):
|
||||
"""
|
||||
Generate a list of search queries for finding guest post opportunities.
|
||||
|
||||
Args:
|
||||
keyword (str): The keyword to base the search queries on.
|
||||
|
||||
Returns:
|
||||
list: A list of search queries.
|
||||
"""
|
||||
return [
|
||||
f"{keyword} + 'Guest Contributor'",
|
||||
f"{keyword} + 'Add Guest Post'",
|
||||
f"{keyword} + 'Guest Bloggers Wanted'",
|
||||
f"{keyword} + 'Write for Us'",
|
||||
f"{keyword} + 'Submit Guest Post'",
|
||||
f"{keyword} + 'Become a Guest Blogger'",
|
||||
f"{keyword} + 'guest post opportunities'",
|
||||
f"{keyword} + 'Submit article'",
|
||||
]
|
||||
|
||||
def find_backlink_opportunities(keyword):
|
||||
"""
|
||||
Find backlink opportunities by scraping websites based on search queries.
|
||||
|
||||
Args:
|
||||
keyword (str): The keyword to search for backlink opportunities.
|
||||
|
||||
Returns:
|
||||
list: A list of results from the scraped websites.
|
||||
"""
|
||||
search_queries = generate_search_queries(keyword)
|
||||
results = []
|
||||
|
||||
# Temporarily disabled Google search functionality
|
||||
# for query in search_queries:
|
||||
# urls = search_for_urls(query)
|
||||
# for url in urls:
|
||||
# website_data = scrape_website(url)
|
||||
# logger.info(f"Scraped Website content for {url}: {website_data}")
|
||||
# if website_data:
|
||||
# contact_info = extract_contact_info(website_data)
|
||||
# logger.info(f"Contact details found for {url}: {contact_info}")
|
||||
|
||||
# Placeholder return for now
|
||||
return []
|
||||
|
||||
def search_for_urls(query):
|
||||
"""
|
||||
Search for URLs using Google search.
|
||||
|
||||
Args:
|
||||
query (str): The search query.
|
||||
|
||||
Returns:
|
||||
list: List of URLs found.
|
||||
"""
|
||||
# Temporarily disabled Google search functionality
|
||||
# return list(search(query, num_results=10))
|
||||
return []
|
||||
|
||||
def compose_personalized_email(website_data, insights, user_proposal):
|
||||
"""
|
||||
Compose a personalized outreach email using AI LLM based on website data, insights, and user proposal.
|
||||
|
||||
Args:
|
||||
website_data (dict): The data of the website including metadata and contact info.
|
||||
insights (str): Insights generated by the LLM about the website.
|
||||
user_proposal (dict): The user's proposal for a guest post or content contribution.
|
||||
|
||||
Returns:
|
||||
str: A personalized email message.
|
||||
"""
|
||||
contact_name = website_data.get("contact_info", {}).get("name", "Webmaster")
|
||||
site_name = website_data.get("metadata", {}).get("title", "your site")
|
||||
proposed_topic = user_proposal.get("topic", "a guest post")
|
||||
user_name = user_proposal.get("user_name", "Your Name")
|
||||
user_email = user_proposal.get("user_email", "your_email@example.com")
|
||||
|
||||
# Refined prompt for email generation
|
||||
email_prompt = f"""
|
||||
You are an AI assistant tasked with composing a highly personalized outreach email for guest posting.
|
||||
|
||||
Contact Name: {contact_name}
|
||||
Website Name: {site_name}
|
||||
Proposed Topic: {proposed_topic}
|
||||
|
||||
User Details:
|
||||
Name: {user_name}
|
||||
Email: {user_email}
|
||||
|
||||
Website Insights: {insights}
|
||||
|
||||
Please compose a professional and engaging email that includes:
|
||||
1. A personalized introduction addressing the recipient.
|
||||
2. A mention of the website's content focus.
|
||||
3. A proposal for a guest post.
|
||||
4. A call to action to discuss the guest post opportunity.
|
||||
5. A polite closing with user contact details.
|
||||
"""
|
||||
|
||||
return llm_text_gen(email_prompt)
|
||||
|
||||
def send_email(smtp_server, smtp_port, smtp_user, smtp_password, to_email, subject, body):
|
||||
"""
|
||||
Send an email using an SMTP server.
|
||||
|
||||
Args:
|
||||
smtp_server (str): The SMTP server address.
|
||||
smtp_port (int): The SMTP server port.
|
||||
smtp_user (str): The SMTP server username.
|
||||
smtp_password (str): The SMTP server password.
|
||||
to_email (str): The recipient's email address.
|
||||
subject (str): The email subject.
|
||||
body (str): The email body.
|
||||
|
||||
Returns:
|
||||
bool: True if the email was sent successfully, False otherwise.
|
||||
"""
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = smtp_user
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = subject
|
||||
msg.attach(MIMEText(body, 'plain'))
|
||||
|
||||
server = smtplib.SMTP(smtp_server, smtp_port)
|
||||
server.starttls()
|
||||
server.login(smtp_user, smtp_password)
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
|
||||
logger.info(f"Email sent successfully to {to_email}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to_email}: {e}")
|
||||
return False
|
||||
|
||||
def extract_contact_info(website_data):
|
||||
"""
|
||||
Extract contact information from website data.
|
||||
|
||||
Args:
|
||||
website_data (dict): Scraped data from the website.
|
||||
|
||||
Returns:
|
||||
dict: Extracted contact information such as name, email, etc.
|
||||
"""
|
||||
# Placeholder for extracting contact information logic
|
||||
return {
|
||||
"name": website_data.get("contact", {}).get("name", "Webmaster"),
|
||||
"email": website_data.get("contact", {}).get("email", ""),
|
||||
}
|
||||
|
||||
def find_backlink_opportunities_for_keywords(keywords):
|
||||
"""
|
||||
Find backlink opportunities for multiple keywords.
|
||||
|
||||
Args:
|
||||
keywords (list): A list of keywords to search for backlink opportunities.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with keywords as keys and a list of results as values.
|
||||
"""
|
||||
all_results = {}
|
||||
for keyword in keywords:
|
||||
results = find_backlink_opportunities(keyword)
|
||||
all_results[keyword] = results
|
||||
return all_results
|
||||
|
||||
def log_sent_email(keyword, email_info):
|
||||
"""
|
||||
Log the information of a sent email.
|
||||
|
||||
Args:
|
||||
keyword (str): The keyword associated with the email.
|
||||
email_info (dict): Information about the sent email (e.g., recipient, subject, body).
|
||||
"""
|
||||
with open(f"{keyword}_sent_emails.log", "a") as log_file:
|
||||
log_file.write(f"{email_info}\n")
|
||||
|
||||
def check_email_responses(imap_server, imap_user, imap_password):
|
||||
"""
|
||||
Check email responses using an IMAP server.
|
||||
|
||||
Args:
|
||||
imap_server (str): The IMAP server address.
|
||||
imap_user (str): The IMAP server username.
|
||||
imap_password (str): The IMAP server password.
|
||||
|
||||
Returns:
|
||||
list: A list of email responses.
|
||||
"""
|
||||
responses = []
|
||||
try:
|
||||
mail = imaplib.IMAP4_SSL(imap_server)
|
||||
mail.login(imap_user, imap_password)
|
||||
mail.select('inbox')
|
||||
|
||||
status, data = mail.search(None, 'UNSEEN')
|
||||
mail_ids = data[0]
|
||||
id_list = mail_ids.split()
|
||||
|
||||
for mail_id in id_list:
|
||||
status, data = mail.fetch(mail_id, '(RFC822)')
|
||||
msg = email.message_from_bytes(data[0][1])
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == 'text/plain':
|
||||
responses.append(part.get_payload(decode=True).decode())
|
||||
else:
|
||||
responses.append(msg.get_payload(decode=True).decode())
|
||||
|
||||
mail.logout()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check email responses: {e}")
|
||||
|
||||
return responses
|
||||
|
||||
def send_follow_up_email(smtp_server, smtp_port, smtp_user, smtp_password, to_email, subject, body):
|
||||
"""
|
||||
Send a follow-up email using an SMTP server.
|
||||
|
||||
Args:
|
||||
smtp_server (str): The SMTP server address.
|
||||
smtp_port (int): The SMTP server port.
|
||||
smtp_user (str): The SMTP server username.
|
||||
smtp_password (str): The SMTP server password.
|
||||
to_email (str): The recipient's email address.
|
||||
subject (str): The email subject.
|
||||
body (str): The email body.
|
||||
|
||||
Returns:
|
||||
bool: True if the email was sent successfully, False otherwise.
|
||||
"""
|
||||
return send_email(smtp_server, smtp_port, smtp_user, smtp_password, to_email, subject, body)
|
||||
|
||||
def main_backlinking_workflow(keywords, smtp_config, imap_config, user_proposal):
|
||||
"""
|
||||
Main workflow for the AI-powered backlinking feature.
|
||||
|
||||
Args:
|
||||
keywords (list): A list of keywords to search for backlink opportunities.
|
||||
smtp_config (dict): SMTP configuration for sending emails.
|
||||
imap_config (dict): IMAP configuration for checking email responses.
|
||||
user_proposal (dict): The user's proposal for a guest post or content contribution.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
all_results = find_backlink_opportunities_for_keywords(keywords)
|
||||
|
||||
for keyword, results in all_results.items():
|
||||
for result in results:
|
||||
email_body = compose_personalized_email(result, result['insights'], user_proposal)
|
||||
email_sent = send_email(
|
||||
smtp_config['server'],
|
||||
smtp_config['port'],
|
||||
smtp_config['user'],
|
||||
smtp_config['password'],
|
||||
result['contact_info']['email'],
|
||||
f"Guest Post Proposal for {result['metadata']['title']}",
|
||||
email_body
|
||||
)
|
||||
if email_sent:
|
||||
log_sent_email(keyword, {
|
||||
"to": result['contact_info']['email'],
|
||||
"subject": f"Guest Post Proposal for {result['metadata']['title']}",
|
||||
"body": email_body
|
||||
})
|
||||
|
||||
responses = check_email_responses(imap_config['server'], imap_config['user'], imap_config['password'])
|
||||
for response in responses:
|
||||
# TBD : Process and possibly send follow-up emails based on responses
|
||||
pass
|
||||
@@ -1,60 +0,0 @@
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
from st_aggrid import AgGrid, GridOptionsBuilder, GridUpdateMode
|
||||
from lib.ai_marketing_tools.ai_backlinker.ai_backlinking import find_backlink_opportunities, compose_personalized_email
|
||||
|
||||
|
||||
# Streamlit UI function
|
||||
def backlinking_ui():
|
||||
st.title("AI Backlinking Tool")
|
||||
|
||||
# Step 1: Get user inputs
|
||||
keyword = st.text_input("Enter a keyword", value="technology")
|
||||
|
||||
# Step 2: Generate backlink opportunities
|
||||
if st.button("Find Backlink Opportunities"):
|
||||
if keyword:
|
||||
backlink_opportunities = find_backlink_opportunities(keyword)
|
||||
|
||||
# Convert results to a DataFrame for display
|
||||
df = pd.DataFrame(backlink_opportunities)
|
||||
|
||||
# Create a selectable table using st-aggrid
|
||||
gb = GridOptionsBuilder.from_dataframe(df)
|
||||
gb.configure_selection('multiple', use_checkbox=True, groupSelectsChildren=True)
|
||||
gridOptions = gb.build()
|
||||
|
||||
grid_response = AgGrid(
|
||||
df,
|
||||
gridOptions=gridOptions,
|
||||
update_mode=GridUpdateMode.SELECTION_CHANGED,
|
||||
height=200,
|
||||
width='100%'
|
||||
)
|
||||
|
||||
selected_rows = grid_response['selected_rows']
|
||||
|
||||
if selected_rows:
|
||||
st.write("Selected Opportunities:")
|
||||
st.table(pd.DataFrame(selected_rows))
|
||||
|
||||
# Step 3: Option to generate personalized emails for selected opportunities
|
||||
if st.button("Generate Emails for Selected Opportunities"):
|
||||
user_proposal = {
|
||||
"user_name": st.text_input("Your Name", value="John Doe"),
|
||||
"user_email": st.text_input("Your Email", value="john@example.com")
|
||||
}
|
||||
|
||||
emails = []
|
||||
for selected in selected_rows:
|
||||
insights = f"Insights based on content from {selected['url']}."
|
||||
email = compose_personalized_email(selected, insights, user_proposal)
|
||||
emails.append(email)
|
||||
|
||||
st.subheader("Generated Emails:")
|
||||
for email in emails:
|
||||
st.write(email)
|
||||
st.markdown("---")
|
||||
|
||||
else:
|
||||
st.error("Please enter a keyword.")
|
||||
@@ -1,370 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
"""
|
||||
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"]
|
||||
@@ -1,327 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,320 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,219 +0,0 @@
|
||||
"""
|
||||
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"
|
||||
]
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -131,6 +131,11 @@ class DatabaseSetup:
|
||||
from services.database import engine
|
||||
from sqlalchemy import inspect
|
||||
|
||||
if engine is None:
|
||||
if verbose:
|
||||
print(" ⚠️ Global engine is None (Multi-tenant mode), skipping global table verification")
|
||||
return True
|
||||
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
@@ -181,20 +186,9 @@ class DatabaseSetup:
|
||||
|
||||
def _setup_monitoring_tables(self) -> bool:
|
||||
"""Set up API monitoring tables."""
|
||||
try:
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
from scripts.create_monitoring_tables import create_monitoring_tables
|
||||
|
||||
if create_monitoring_tables():
|
||||
print(" ✅ API monitoring tables created")
|
||||
return True
|
||||
else:
|
||||
print(" ⚠️ API monitoring setup failed")
|
||||
return True # Non-critical
|
||||
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Monitoring setup failed: {e}")
|
||||
return True # Non-critical
|
||||
# Reuse the existing method that uses SQLAlchemy metadata
|
||||
# This avoids the script dependency that requires user_id
|
||||
return self._create_monitoring_tables()
|
||||
|
||||
def _setup_billing_tables(self) -> bool:
|
||||
"""Set up billing and subscription tables."""
|
||||
@@ -203,17 +197,23 @@ class DatabaseSetup:
|
||||
from scripts.create_billing_tables import create_billing_tables, check_existing_tables
|
||||
from services.database import engine
|
||||
|
||||
# Check if engine is available (it might be None in multi-tenant mode)
|
||||
if engine is None:
|
||||
# In multi-tenant mode, we can't setup global billing tables
|
||||
# They will be created per-user when they are initialized
|
||||
return True
|
||||
|
||||
# Check if tables already exist
|
||||
if check_existing_tables(engine):
|
||||
logger.debug("✅ Billing tables already exist")
|
||||
return True
|
||||
|
||||
if create_billing_tables():
|
||||
logger.debug("✅ Billing tables created")
|
||||
return True
|
||||
else:
|
||||
logger.warning("Billing setup failed")
|
||||
return True # Non-critical
|
||||
# For global setup, we can't call create_billing_tables() without user_id
|
||||
# But if engine is not None, it implies we have a global DB.
|
||||
# However, the script is designed for user_id.
|
||||
# We'll skip this call to avoid the TypeError and rely on per-user init.
|
||||
logger.debug("ℹ️ Skipping global billing table creation (handled per-user)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Billing setup failed: {e}")
|
||||
|
||||
@@ -364,7 +364,7 @@ class OnboardingManager:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@self.app.get("/api/onboarding/business-info/user/{user_id}")
|
||||
async def business_info_get_by_user(user_id: int):
|
||||
async def business_info_get_by_user(user_id: str):
|
||||
"""Get business information by user ID."""
|
||||
try:
|
||||
return await get_business_info_by_user(user_id)
|
||||
|
||||
@@ -6,6 +6,7 @@ Handles FastAPI router inclusion and management.
|
||||
from fastapi import FastAPI
|
||||
from loguru import logger
|
||||
from typing import List, Dict, Any, Optional
|
||||
import os
|
||||
|
||||
|
||||
class RouterManager:
|
||||
@@ -18,7 +19,6 @@ class RouterManager:
|
||||
|
||||
def include_router_safely(self, router, router_name: str = None) -> bool:
|
||||
"""Include a router safely with error handling."""
|
||||
import os
|
||||
verbose = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true"
|
||||
|
||||
try:
|
||||
@@ -37,6 +37,7 @@ class RouterManager:
|
||||
|
||||
def include_core_routers(self) -> bool:
|
||||
"""Include core application routers."""
|
||||
# Import os locally to avoid UnboundLocalError if it's shadowed
|
||||
import os
|
||||
verbose = os.getenv("ALWRITY_VERBOSE", "false").lower() == "true"
|
||||
|
||||
@@ -55,6 +56,13 @@ class RouterManager:
|
||||
# Step 3 Research router (core onboarding functionality)
|
||||
from api.onboarding_utils.step3_routes import router as step3_research_router
|
||||
self.include_router_safely(step3_research_router, "step3_research")
|
||||
|
||||
# Step 4 Persona and Asset routers
|
||||
from api.onboarding_utils.step4_asset_routes import router as step4_asset_router
|
||||
self.include_router_safely(step4_asset_router, "step4_assets")
|
||||
|
||||
from api.onboarding_utils.step4_persona_routes_optimized import router as step4_persona_router
|
||||
self.include_router_safely(step4_persona_router, "step4_persona")
|
||||
|
||||
# GSC router
|
||||
from routers.gsc_auth import router as gsc_auth_router
|
||||
|
||||
1067
backend/api/agents_api.py
Normal file
1067
backend/api/agents_api.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@ from models.blog_models import (
|
||||
)
|
||||
from services.blog_writer.blog_service import BlogWriterService
|
||||
from services.blog_writer.seo.blog_seo_recommendation_applier import BlogSEORecommendationApplier
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from .task_manager import task_manager
|
||||
from .cache_manager import cache_manager
|
||||
from models.blog_models import MediumBlogGenerateRequest
|
||||
@@ -97,6 +98,217 @@ async def apply_seo_recommendations(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class BlogSectionToolRequest(BaseModel):
|
||||
section_id: str = Field(..., description="Section id in blog writer UI")
|
||||
title: Optional[str] = Field(default=None, description="Section title/heading")
|
||||
content: str = Field(..., description="Section content text")
|
||||
keywords: List[str] = Field(default_factory=list, description="Optional target keywords")
|
||||
goal: Optional[str] = Field(default=None, description="Optional optimization goal")
|
||||
|
||||
|
||||
@router.post("/section/tools/originality")
|
||||
async def section_originality_tools(
|
||||
request: BlogSectionToolRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
user_id = str(current_user.get("id"))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User ID not found in authentication token")
|
||||
|
||||
from services.intelligence.sif_integration import SIFIntegrationService
|
||||
from services.intelligence.sif_agents import ContentGuardianAgent
|
||||
|
||||
sif_service = SIFIntegrationService(user_id)
|
||||
intelligence = sif_service.intelligence_service
|
||||
|
||||
content = (request.content or "").strip()
|
||||
if len(content) < 50:
|
||||
return {
|
||||
"success": False,
|
||||
"section_id": request.section_id,
|
||||
"error": "Content too short for originality check",
|
||||
"matches": [],
|
||||
}
|
||||
|
||||
matches = await intelligence.search(content, limit=5)
|
||||
normalized_matches = []
|
||||
for m in matches or []:
|
||||
normalized_matches.append(
|
||||
{
|
||||
"id": m.get("id"),
|
||||
"score": m.get("score", 0.0),
|
||||
"excerpt": (m.get("text", "") or "")[:240],
|
||||
}
|
||||
)
|
||||
|
||||
guardian = ContentGuardianAgent(intelligence, sif_service=sif_service)
|
||||
cannibalization = await guardian.check_cannibalization(content)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"section_id": request.section_id,
|
||||
"cannibalization": cannibalization,
|
||||
"matches": normalized_matches,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run originality tools: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/section/tools/internal-links")
|
||||
async def section_internal_link_tools(
|
||||
request: BlogSectionToolRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
user_id = str(current_user.get("id"))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User ID not found in authentication token")
|
||||
|
||||
from services.intelligence.sif_integration import SIFIntegrationService
|
||||
from services.intelligence.sif_agents import LinkGraphAgent
|
||||
|
||||
sif_service = SIFIntegrationService(user_id)
|
||||
intelligence = sif_service.intelligence_service
|
||||
|
||||
content = (request.content or "").strip()
|
||||
suggestions = []
|
||||
if len(content) >= 50:
|
||||
link_agent = LinkGraphAgent(intelligence, sif_service=sif_service)
|
||||
suggestions = await link_agent.link_suggester(content)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"section_id": request.section_id,
|
||||
"suggestions": suggestions or [],
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run internal link tools: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/section/tools/fact-check")
|
||||
async def section_fact_check_tools(
|
||||
request: BlogSectionToolRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
user_id = str(current_user.get("id"))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User ID not found in authentication token")
|
||||
|
||||
from services.intelligence.sif_integration import SIFIntegrationService
|
||||
from services.intelligence.sif_agents import CitationExpert
|
||||
|
||||
sif_service = SIFIntegrationService(user_id)
|
||||
intelligence = sif_service.intelligence_service
|
||||
|
||||
expert = CitationExpert(intelligence)
|
||||
content = (request.content or "").strip()
|
||||
|
||||
verification = await expert.claim_verifier(content)
|
||||
|
||||
topic = request.title or content[:120]
|
||||
citations = await expert.citation_finder(topic)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"section_id": request.section_id,
|
||||
"verification": verification,
|
||||
"citations": citations or [],
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run fact check tools: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/section/tools/optimize")
|
||||
async def section_optimize_tools(
|
||||
request: BlogSectionToolRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
user_id = str(current_user.get("id"))
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="User ID not found in authentication token")
|
||||
|
||||
content = (request.content or "").strip()
|
||||
if len(content) < 50:
|
||||
return {
|
||||
"success": False,
|
||||
"section_id": request.section_id,
|
||||
"error": "Content too short for optimization",
|
||||
}
|
||||
|
||||
goal = request.goal or "readability"
|
||||
keywords_str = ", ".join(request.keywords or [])
|
||||
|
||||
system_prompt = (
|
||||
"You are an expert editor. Optimize the provided blog section while preserving meaning and tone."
|
||||
)
|
||||
prompt = (
|
||||
f"Optimization goal: {goal}\n"
|
||||
f"Target keywords (if any): {keywords_str}\n"
|
||||
f"Section title: {request.title or ''}\n\n"
|
||||
"Return a JSON object with keys:\n"
|
||||
'- optimized_content: string\n'
|
||||
'- changes_made: array of strings\n'
|
||||
"- diff_summary: string\n\n"
|
||||
f"Section content:\n{content}\n"
|
||||
)
|
||||
|
||||
json_struct = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"optimized_content": {"type": "string"},
|
||||
"changes_made": {"type": "array", "items": {"type": "string"}},
|
||||
"diff_summary": {"type": "string"},
|
||||
},
|
||||
"required": ["optimized_content", "changes_made", "diff_summary"],
|
||||
}
|
||||
|
||||
raw = llm_text_gen(prompt=prompt, system_prompt=system_prompt, json_struct=json_struct, user_id=user_id)
|
||||
data = None
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
data = _json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
data = {
|
||||
"optimized_content": raw,
|
||||
"changes_made": ["Optimization applied"],
|
||||
"diff_summary": "Generated optimized version",
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"section_id": request.section_id,
|
||||
"optimized_content": data.get("optimized_content"),
|
||||
"changes_made": data.get("changes_made", []),
|
||||
"diff_summary": data.get("diff_summary"),
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run optimize tools: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> Dict[str, Any]:
|
||||
@@ -286,7 +498,8 @@ async def generate_section(
|
||||
) -> BlogSectionResponse:
|
||||
"""Generate content for a specific section."""
|
||||
try:
|
||||
response = await service.generate_section(request)
|
||||
user_id = str(current_user.get('id', '')) if current_user else None
|
||||
response = await service.generate_section(request, user_id=user_id)
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
if response.markdown:
|
||||
@@ -981,4 +1194,4 @@ async def generate_introductions(
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate introductions: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -10,10 +10,14 @@ from pydantic import BaseModel
|
||||
from typing import Dict, Any, Optional
|
||||
from loguru import logger
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
|
||||
from services.blog_writer.seo.blog_content_seo_analyzer import BlogContentSEOAnalyzer
|
||||
from services.blog_writer.core.blog_writer_service import BlogWriterService
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from services.database import get_db
|
||||
from models.seo_analysis import SEOAnalysis
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/blog-writer/seo", tags=["Blog SEO Analysis"])
|
||||
@@ -147,7 +151,8 @@ async def analyze_blog_seo(
|
||||
@router.post("/analyze-with-progress")
|
||||
async def analyze_blog_seo_with_progress(
|
||||
request: SEOAnalysisRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Analyze blog content for SEO with real-time progress updates
|
||||
@@ -158,6 +163,7 @@ async def analyze_blog_seo_with_progress(
|
||||
Args:
|
||||
request: SEOAnalysisRequest containing blog content and research data
|
||||
current_user: Authenticated user from middleware
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Generator yielding progress updates and final results
|
||||
@@ -240,6 +246,35 @@ async def analyze_blog_seo_with_progress(
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Save to Database
|
||||
try:
|
||||
draft_url = f"draft:{analysis_id}"
|
||||
overall_score = analysis_results.get('overall_score', 0)
|
||||
|
||||
# Determine health status
|
||||
if overall_score >= 90:
|
||||
health_status = "excellent"
|
||||
elif overall_score >= 70:
|
||||
health_status = "good"
|
||||
elif overall_score >= 50:
|
||||
health_status = "needs_improvement"
|
||||
else:
|
||||
health_status = "poor"
|
||||
|
||||
new_analysis = SEOAnalysis(
|
||||
url=draft_url,
|
||||
overall_score=int(overall_score),
|
||||
health_status=health_status,
|
||||
timestamp=datetime.utcnow(),
|
||||
analysis_data=analysis_results
|
||||
)
|
||||
db.add(new_analysis)
|
||||
db.commit()
|
||||
logger.info(f"Saved SEO analysis results to DB for ID: {analysis_id}")
|
||||
except Exception as db_error:
|
||||
logger.error(f"Failed to save analysis to DB: {db_error}")
|
||||
# Continue without failing
|
||||
|
||||
# Final result
|
||||
yield SEOAnalysisProgress(
|
||||
analysis_id=analysis_id,
|
||||
@@ -273,27 +308,46 @@ async def analyze_blog_seo_with_progress(
|
||||
|
||||
|
||||
@router.get("/analysis/{analysis_id}")
|
||||
async def get_analysis_result(analysis_id: str):
|
||||
async def get_analysis_result(
|
||||
analysis_id: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get SEO analysis result by ID
|
||||
|
||||
Args:
|
||||
analysis_id: Unique identifier for the analysis
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
SEO analysis results
|
||||
"""
|
||||
try:
|
||||
# In a real implementation, you would store results in a database
|
||||
# For now, we'll return a placeholder
|
||||
logger.info(f"Retrieving SEO analysis result for ID: {analysis_id}")
|
||||
|
||||
return {
|
||||
"analysis_id": analysis_id,
|
||||
"status": "completed",
|
||||
"message": "Analysis results retrieved successfully"
|
||||
}
|
||||
# Look for the analysis in the database
|
||||
draft_url = f"draft:{analysis_id}"
|
||||
stmt = select(SEOAnalysis).where(SEOAnalysis.url == draft_url)
|
||||
analysis = db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if analysis and analysis.analysis_data:
|
||||
# Return stored analysis data
|
||||
return {
|
||||
"analysis_id": analysis_id,
|
||||
"status": "completed",
|
||||
"message": "Analysis results retrieved successfully",
|
||||
**analysis.analysis_data
|
||||
}
|
||||
|
||||
# If not found in DB (fallback for legacy or in-memory only)
|
||||
# For now, we return 404 to encourage DB usage, or we could return a placeholder if strictly needed.
|
||||
# But user requested DB integration, so we should rely on DB.
|
||||
|
||||
logger.warning(f"Analysis result not found in DB for ID: {analysis_id}")
|
||||
raise HTTPException(status_code=404, detail="Analysis result not found")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Get analysis result error: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to retrieve analysis result: {str(e)}")
|
||||
|
||||
@@ -12,6 +12,8 @@ from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
from services.database import SessionLocal, get_session_for_user
|
||||
|
||||
from models.blog_models import (
|
||||
BlogResearchRequest,
|
||||
@@ -261,11 +263,17 @@ class TaskManager:
|
||||
if total_target > 1000:
|
||||
raise ValueError("Global target words exceed 1000; medium generation not allowed")
|
||||
|
||||
result: MediumBlogGenerateResult = await self.service.generate_medium_blog_with_progress(
|
||||
request,
|
||||
task_id,
|
||||
user_id
|
||||
)
|
||||
# Create a sync session for asset saving
|
||||
db_session = SessionLocal()
|
||||
try:
|
||||
result: MediumBlogGenerateResult = await self.service.generate_medium_blog_with_progress(
|
||||
request,
|
||||
task_id,
|
||||
user_id,
|
||||
db=db_session
|
||||
)
|
||||
finally:
|
||||
db_session.close()
|
||||
|
||||
if not result or not getattr(result, "sections", None):
|
||||
raise ValueError("Empty generation result from model")
|
||||
|
||||
@@ -31,13 +31,13 @@ from services.component_logic.style_detection_logic import StyleDetectionLogic
|
||||
from services.component_logic.web_crawler_logic import WebCrawlerLogic
|
||||
from services.research_preferences_service import ResearchPreferencesService
|
||||
from services.database import get_db
|
||||
from services.onboarding import OnboardingDatabaseService
|
||||
|
||||
# Import authentication for user isolation
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
# Import the website analysis service
|
||||
from services.website_analysis_service import WebsiteAnalysisService
|
||||
from services.seo_tools.sitemap_service import SitemapService
|
||||
from services.database import get_db_session
|
||||
|
||||
# Initialize services
|
||||
@@ -67,12 +67,33 @@ def clerk_user_id_to_int(user_id: str) -> int:
|
||||
|
||||
|
||||
def _get_onboarding_session(db_session: Session, user_id: str, create_if_missing: bool = False) -> Optional[OnboardingSession]:
|
||||
"""Fetch onboarding session for a user, optionally creating one."""
|
||||
db_service = OnboardingDatabaseService(db_session)
|
||||
session = db_service.get_session_by_user(user_id, db_session)
|
||||
if not session and create_if_missing:
|
||||
session = db_service.get_or_create_session(user_id, db_session)
|
||||
return session
|
||||
"""Fetch onboarding session for a user, optionally creating one.
|
||||
Refactored to use direct DB access instead of legacy OnboardingDatabaseService.
|
||||
"""
|
||||
try:
|
||||
session = db_session.query(OnboardingSession).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not session and create_if_missing:
|
||||
logger.info(f"Creating new onboarding session for user {user_id}")
|
||||
session = OnboardingSession(
|
||||
user_id=user_id,
|
||||
current_step=1,
|
||||
progress=0.0,
|
||||
started_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
db_session.add(session)
|
||||
db_session.commit()
|
||||
db_session.refresh(session)
|
||||
|
||||
return session
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting/creating onboarding session: {e}")
|
||||
if create_if_missing:
|
||||
db_session.rollback()
|
||||
return None
|
||||
|
||||
# AI Research Endpoints
|
||||
|
||||
@@ -218,8 +239,12 @@ async def validate_content_style(request: ContentStyleRequest):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in validate_content_style: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error(f"Error in validate_content_style: {str(e)}", exc_info=True)
|
||||
return ContentStyleResponse(
|
||||
valid=False,
|
||||
style_config=None,
|
||||
errors=[f"Internal error validating content style: {str(e)}"]
|
||||
)
|
||||
|
||||
@router.post("/personalization/configure-brand", response_model=BrandVoiceResponse)
|
||||
async def configure_brand_voice(request: BrandVoiceRequest):
|
||||
@@ -242,8 +267,12 @@ async def configure_brand_voice(request: BrandVoiceRequest):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in configure_brand_voice: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error(f"Error in configure_brand_voice: {str(e)}", exc_info=True)
|
||||
return BrandVoiceResponse(
|
||||
valid=False,
|
||||
brand_config=None,
|
||||
errors=[f"Internal error configuring brand voice: {str(e)}"]
|
||||
)
|
||||
|
||||
@router.post("/personalization/process-settings", response_model=PersonalizationSettingsResponse)
|
||||
async def process_personalization_settings(request: PersonalizationSettingsRequest):
|
||||
@@ -278,8 +307,12 @@ async def process_personalization_settings(request: PersonalizationSettingsReque
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in process_personalization_settings: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error(f"Error in process_personalization_settings: {str(e)}", exc_info=True)
|
||||
return PersonalizationSettingsResponse(
|
||||
valid=False,
|
||||
settings=None,
|
||||
errors=[f"Internal error processing settings: {str(e)}"]
|
||||
)
|
||||
|
||||
@router.get("/personalization/configuration-options")
|
||||
async def get_personalization_configuration_options():
|
||||
@@ -295,8 +328,21 @@ async def get_personalization_configuration_options():
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_personalization_configuration_options: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error(f"Error in get_personalization_configuration_options: {str(e)}", exc_info=True)
|
||||
# Fallback to default options to prevent 500 error
|
||||
return {
|
||||
'success': False,
|
||||
'options': {
|
||||
'writing_styles': ["Professional", "Casual", "Technical", "Conversational", "Academic"],
|
||||
'tones': ["Formal", "Semi-Formal", "Neutral", "Friendly", "Humorous"],
|
||||
'content_lengths': ["Concise", "Standard", "Detailed", "Comprehensive"],
|
||||
'personality_traits': ["Professional", "Innovative", "Friendly", "Trustworthy", "Creative", "Expert"],
|
||||
'readability_levels': ["Simple", "Standard", "Advanced", "Expert"],
|
||||
'content_structures': ["Introduction", "Key Points", "Examples", "Conclusion", "Call-to-Action"],
|
||||
'seo_optimization_options': [True, False]
|
||||
},
|
||||
'message': f"Error loading options: {str(e)}"
|
||||
}
|
||||
|
||||
@router.post("/personalization/generate-guidelines")
|
||||
async def generate_content_guidelines(settings: Dict[str, Any]):
|
||||
@@ -395,10 +441,14 @@ async def generate_research_report(results: Dict[str, Any]):
|
||||
|
||||
# Style Detection Endpoints
|
||||
@router.post("/style-detection/analyze", response_model=StyleAnalysisResponse)
|
||||
async def analyze_content_style(request: StyleAnalysisRequest):
|
||||
async def analyze_content_style(
|
||||
request: StyleAnalysisRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Analyze content style using AI."""
|
||||
try:
|
||||
logger.info("[analyze_content_style] Starting style analysis")
|
||||
user_id = str(current_user.get('id'))
|
||||
logger.info(f"[analyze_content_style] Starting style analysis for user: {user_id}")
|
||||
|
||||
# Initialize style detection logic
|
||||
style_logic = StyleDetectionLogic()
|
||||
@@ -414,9 +464,9 @@ async def analyze_content_style(request: StyleAnalysisRequest):
|
||||
|
||||
# Perform style analysis
|
||||
if request.analysis_type == "comprehensive":
|
||||
result = style_logic.analyze_content_style(validation['content'])
|
||||
result = style_logic.analyze_content_style(validation['content'], user_id=user_id)
|
||||
elif request.analysis_type == "patterns":
|
||||
result = style_logic.analyze_style_patterns(validation['content'])
|
||||
result = style_logic.analyze_style_patterns(validation['content'], user_id=user_id)
|
||||
else:
|
||||
return StyleAnalysisResponse(
|
||||
success=False,
|
||||
@@ -515,7 +565,7 @@ async def complete_style_detection(
|
||||
logger.info(f"[complete_style_detection] Starting complete style detection for user: {user_id}")
|
||||
|
||||
# Get database session
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
if not db_session:
|
||||
return StyleDetectionResponse(
|
||||
success=False,
|
||||
@@ -527,6 +577,7 @@ async def complete_style_detection(
|
||||
crawler_logic = WebCrawlerLogic()
|
||||
style_logic = StyleDetectionLogic()
|
||||
analysis_service = WebsiteAnalysisService(db_session)
|
||||
sitemap_service = SitemapService()
|
||||
|
||||
session = _get_onboarding_session(db_session, user_id, create_if_missing=True)
|
||||
if not session:
|
||||
@@ -573,19 +624,49 @@ async def complete_style_detection(
|
||||
async def run_style_analysis():
|
||||
"""Run style analysis in executor"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, partial(style_logic.analyze_content_style, crawl_result['content']))
|
||||
return await loop.run_in_executor(None, partial(style_logic.analyze_content_style, crawl_result['content'], user_id=user_id))
|
||||
|
||||
async def run_patterns_analysis():
|
||||
"""Run patterns analysis in executor (if requested)"""
|
||||
if not request.include_patterns:
|
||||
return None
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, partial(style_logic.analyze_style_patterns, crawl_result['content']))
|
||||
return await loop.run_in_executor(None, partial(style_logic.analyze_style_patterns, crawl_result['content'], user_id=user_id))
|
||||
|
||||
# Execute style and patterns analysis in parallel
|
||||
style_analysis, patterns_result = await asyncio.gather(
|
||||
async def run_seo_audit():
|
||||
"""Run SEO audit in executor"""
|
||||
if not request.url:
|
||||
return None
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, partial(style_logic.perform_seo_audit, request.url, crawl_result['content']))
|
||||
|
||||
async def run_sitemap_analysis():
|
||||
"""Run AI sitemap analysis for home page"""
|
||||
if not request.url:
|
||||
return None
|
||||
try:
|
||||
# Discover sitemap URL
|
||||
sitemap_url = await sitemap_service.discover_sitemap_url(request.url)
|
||||
if sitemap_url:
|
||||
# Analyze sitemap with AI insights
|
||||
return await sitemap_service.analyze_sitemap(
|
||||
sitemap_url=sitemap_url,
|
||||
analyze_content_trends=True,
|
||||
analyze_publishing_patterns=True,
|
||||
include_ai_insights=True,
|
||||
user_id=user_id
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Sitemap analysis failed: {e}")
|
||||
return None
|
||||
|
||||
# Execute style, patterns, SEO analysis and sitemap analysis in parallel
|
||||
style_analysis, patterns_result, seo_audit_result, sitemap_result = await asyncio.gather(
|
||||
run_style_analysis(),
|
||||
run_patterns_analysis(),
|
||||
run_seo_audit(),
|
||||
run_sitemap_analysis(),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
@@ -622,13 +703,27 @@ async def complete_style_detection(
|
||||
if patterns_result.get('success'):
|
||||
style_patterns = patterns_result.get('patterns')
|
||||
|
||||
# Process SEO audit result
|
||||
seo_audit = None
|
||||
if seo_audit_result and not isinstance(seo_audit_result, Exception):
|
||||
seo_audit = seo_audit_result
|
||||
elif isinstance(seo_audit_result, Exception):
|
||||
logger.warning(f"SEO audit failed: {seo_audit_result}")
|
||||
|
||||
# Process sitemap analysis result
|
||||
sitemap_analysis = None
|
||||
if sitemap_result and not isinstance(sitemap_result, Exception):
|
||||
sitemap_analysis = sitemap_result
|
||||
elif isinstance(sitemap_result, Exception):
|
||||
logger.warning(f"Sitemap analysis failed: {sitemap_result}")
|
||||
|
||||
# Step 4: Generate guidelines (depends on style_analysis, must run after)
|
||||
style_guidelines = None
|
||||
if request.include_guidelines:
|
||||
loop = asyncio.get_event_loop()
|
||||
guidelines_result = await loop.run_in_executor(
|
||||
None,
|
||||
partial(style_logic.generate_style_guidelines, style_analysis.get('analysis', {}))
|
||||
partial(style_logic.generate_style_guidelines, style_analysis.get('analysis', {}), user_id=user_id)
|
||||
)
|
||||
if guidelines_result and guidelines_result.get('success'):
|
||||
style_guidelines = guidelines_result.get('guidelines')
|
||||
@@ -644,6 +739,8 @@ async def complete_style_detection(
|
||||
'style_analysis': style_analysis.get('analysis') if style_analysis else None,
|
||||
'style_patterns': style_patterns,
|
||||
'style_guidelines': style_guidelines,
|
||||
'seo_audit': seo_audit,
|
||||
'sitemap_analysis': sitemap_analysis,
|
||||
'warning': warning
|
||||
}
|
||||
|
||||
@@ -659,6 +756,8 @@ async def complete_style_detection(
|
||||
style_analysis=style_analysis.get('analysis') if style_analysis else None,
|
||||
style_patterns=style_patterns,
|
||||
style_guidelines=style_guidelines,
|
||||
seo_audit=seo_audit,
|
||||
sitemap_analysis=sitemap_analysis,
|
||||
warning=warning,
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
@@ -682,19 +781,20 @@ async def check_existing_analysis(
|
||||
logger.info(f"[check_existing_analysis] Checking for URL: {website_url} (user: {user_id})")
|
||||
|
||||
# Get database session
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
if not db_session:
|
||||
return {"error": "Database connection not available"}
|
||||
|
||||
# Initialize service
|
||||
analysis_service = WebsiteAnalysisService(db_session)
|
||||
|
||||
# Use authenticated Clerk user ID for proper user isolation
|
||||
# Use consistent SHA256-based conversion
|
||||
user_id_int = clerk_user_id_to_int(user_id)
|
||||
# Get onboarding session to ensure we check the correct session
|
||||
session = _get_onboarding_session(db_session, user_id)
|
||||
if not session:
|
||||
return {'exists': False}
|
||||
|
||||
# Check for existing analysis for THIS USER ONLY
|
||||
existing_analysis = analysis_service.check_existing_analysis(user_id_int, website_url)
|
||||
# Check for existing analysis for THIS USER'S SESSION
|
||||
existing_analysis = analysis_service.check_existing_analysis(session.id, website_url)
|
||||
|
||||
return existing_analysis
|
||||
|
||||
@@ -703,23 +803,33 @@ async def check_existing_analysis(
|
||||
return {"error": f"Error checking existing analysis: {str(e)}"}
|
||||
|
||||
@router.get("/style-detection/analysis/{analysis_id}")
|
||||
async def get_analysis_by_id(analysis_id: int):
|
||||
async def get_analysis_by_id(
|
||||
analysis_id: int,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Get analysis by ID."""
|
||||
try:
|
||||
logger.info(f"[get_analysis_by_id] Getting analysis: {analysis_id}")
|
||||
user_id = str(current_user.get('id'))
|
||||
logger.info(f"[get_analysis_by_id] Getting analysis: {analysis_id} (user: {user_id})")
|
||||
|
||||
# Get database session
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
if not db_session:
|
||||
return {"error": "Database connection not available"}
|
||||
|
||||
# Initialize service
|
||||
analysis_service = WebsiteAnalysisService(db_session)
|
||||
|
||||
# Get onboarding session to ensure ownership
|
||||
session = _get_onboarding_session(db_session, user_id)
|
||||
if not session:
|
||||
return {"success": False, "error": "Analysis not found"}
|
||||
|
||||
# Get analysis
|
||||
analysis = analysis_service.get_analysis(analysis_id)
|
||||
|
||||
if analysis:
|
||||
# Verify ownership (session_id must match)
|
||||
if analysis and analysis.get('session_id') == session.id:
|
||||
return {"success": True, "analysis": analysis}
|
||||
else:
|
||||
return {"success": False, "error": "Analysis not found"}
|
||||
@@ -733,22 +843,23 @@ async def get_session_analyses(current_user: Dict[str, Any] = Depends(get_curren
|
||||
"""Get all analyses for the current user with proper user isolation."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
logger.info(f"[get_session_analyses] Getting analyses for user: {user_id}")
|
||||
logger.info(f"[get_session_analyses] Getting analyses for user: {user_id})")
|
||||
|
||||
# Get database session
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
if not db_session:
|
||||
return {"error": "Database connection not available"}
|
||||
|
||||
# Initialize service
|
||||
analysis_service = WebsiteAnalysisService(db_session)
|
||||
|
||||
# Use authenticated Clerk user ID for proper user isolation
|
||||
# Use consistent SHA256-based conversion
|
||||
user_id_int = clerk_user_id_to_int(user_id)
|
||||
# Get onboarding session to ensure we fetch analyses for the correct session
|
||||
session = _get_onboarding_session(db_session, user_id)
|
||||
if not session:
|
||||
return {"success": True, "analyses": []}
|
||||
|
||||
# Get analyses for THIS USER ONLY (not all users!)
|
||||
analyses = analysis_service.get_session_analyses(user_id_int)
|
||||
# Get analyses for THIS USER'S SESSION
|
||||
analyses = analysis_service.get_session_analyses(session.id)
|
||||
|
||||
logger.info(f"[get_session_analyses] Found {len(analyses) if analyses else 0} analyses for user {user_id}")
|
||||
return {"success": True, "analyses": analyses}
|
||||
@@ -757,28 +868,107 @@ async def get_session_analyses(current_user: Dict[str, Any] = Depends(get_curren
|
||||
logger.error(f"[get_session_analyses] Error: {str(e)}")
|
||||
return {"error": f"Error retrieving session analyses: {str(e)}"}
|
||||
|
||||
@router.delete("/style-detection/analysis/{analysis_id}")
|
||||
async def delete_analysis(analysis_id: int):
|
||||
"""Delete an analysis."""
|
||||
@router.put("/style-detection/analysis/{analysis_id}")
|
||||
async def update_analysis(
|
||||
analysis_id: int,
|
||||
analysis_data: Dict[str, Any],
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Update an existing analysis with edited content."""
|
||||
try:
|
||||
logger.info(f"[delete_analysis] Deleting analysis: {analysis_id}")
|
||||
user_id = str(current_user.get('id'))
|
||||
logger.info(f"[update_analysis] Updating analysis: {analysis_id} (user: {user_id})")
|
||||
|
||||
# Get database session
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
if not db_session:
|
||||
return {"error": "Database connection not available"}
|
||||
|
||||
# Initialize service
|
||||
analysis_service = WebsiteAnalysisService(db_session)
|
||||
|
||||
# Get onboarding session to ensure ownership
|
||||
session = _get_onboarding_session(db_session, user_id)
|
||||
if not session:
|
||||
return {"success": False, "error": "Analysis not found"}
|
||||
|
||||
# Check ownership first
|
||||
analysis = analysis_service.get_analysis(analysis_id)
|
||||
if not analysis or analysis.get('session_id') != session.id:
|
||||
return {"success": False, "error": "Analysis not found"}
|
||||
|
||||
# Update analysis
|
||||
# Reconstruct style_guidelines if individual fields are passed
|
||||
# The frontend flat structure: guidelines, best_practices, etc.
|
||||
# The DB structure: style_guidelines JSON
|
||||
|
||||
if any(k in analysis_data for k in ['guidelines', 'best_practices', 'avoid_elements', 'content_strategy', 'ai_generation_tips', 'competitive_advantages', 'content_calendar_suggestions']):
|
||||
# Fetch existing style_guidelines to merge or create new
|
||||
existing_guidelines = analysis.get('style_guidelines') or {}
|
||||
|
||||
mapping = {
|
||||
'guidelines': 'guidelines',
|
||||
'best_practices': 'best_practices',
|
||||
'avoid_elements': 'avoid_elements',
|
||||
'content_strategy': 'content_strategy',
|
||||
'ai_generation_tips': 'ai_generation_tips',
|
||||
'competitive_advantages': 'competitive_advantages',
|
||||
'content_calendar_suggestions': 'content_calendar_suggestions'
|
||||
}
|
||||
|
||||
for frontend_key, db_key in mapping.items():
|
||||
if frontend_key in analysis_data:
|
||||
existing_guidelines[db_key] = analysis_data[frontend_key]
|
||||
|
||||
analysis_data['style_guidelines'] = existing_guidelines
|
||||
|
||||
success = analysis_service.update_analysis_content(analysis_id, analysis_data)
|
||||
|
||||
if success:
|
||||
return {"success": True}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to update analysis"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[update_analysis] Error: {str(e)}")
|
||||
return {"error": f"Error updating analysis: {str(e)}"}
|
||||
|
||||
@router.delete("/style-detection/analysis/{analysis_id}")
|
||||
async def delete_analysis(
|
||||
analysis_id: int,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Delete an analysis."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
logger.info(f"[delete_analysis] Deleting analysis: {analysis_id} (user: {user_id})")
|
||||
|
||||
# Get database session
|
||||
db_session = get_db_session(user_id)
|
||||
if not db_session:
|
||||
return {"error": "Database connection not available"}
|
||||
|
||||
# Initialize service
|
||||
analysis_service = WebsiteAnalysisService(db_session)
|
||||
|
||||
# Get onboarding session to ensure ownership
|
||||
session = _get_onboarding_session(db_session, user_id)
|
||||
if not session:
|
||||
return {"success": False, "error": "Analysis not found"}
|
||||
|
||||
# Check ownership first
|
||||
analysis = analysis_service.get_analysis(analysis_id)
|
||||
if not analysis or analysis.get('session_id') != session.id:
|
||||
return {"success": False, "error": "Analysis not found"}
|
||||
|
||||
# Delete analysis
|
||||
success = analysis_service.delete_analysis(analysis_id)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": "Analysis deleted successfully"}
|
||||
return {"success": True}
|
||||
else:
|
||||
return {"success": False, "error": "Analysis not found or could not be deleted"}
|
||||
|
||||
return {"success": False, "error": "Failed to delete analysis"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[delete_analysis] Error: {str(e)}")
|
||||
return {"error": f"Error deleting analysis: {str(e)}"}
|
||||
|
||||
@@ -54,7 +54,7 @@ async def accept_autofill_inputs(
|
||||
"""Persist end-user accepted auto-fill inputs and associate with the strategy."""
|
||||
try:
|
||||
logger.info(f"🚀 Accepting autofill inputs for strategy: {strategy_id}")
|
||||
user_id = int(payload.get('user_id') or 1)
|
||||
user_id = str(payload.get('user_id') or "")
|
||||
accepted_fields = payload.get('accepted_fields') or {}
|
||||
# Optional transparency bundles
|
||||
sources = payload.get('sources') or {}
|
||||
@@ -224,4 +224,4 @@ async def refresh_autofill(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error generating fresh auto-fill payload: {str(e)}")
|
||||
raise ContentPlanningErrorHandler.handle_general_error(e, "refresh_autofill")
|
||||
raise ContentPlanningErrorHandler.handle_general_error(e, "refresh_autofill")
|
||||
|
||||
@@ -11,7 +11,7 @@ import json
|
||||
from datetime import datetime
|
||||
|
||||
# Import database
|
||||
from services.database import get_db_session
|
||||
from services.database import get_db
|
||||
|
||||
# Import authentication middleware
|
||||
from middleware.auth_middleware import get_current_user
|
||||
@@ -31,13 +31,6 @@ from ....utils.data_parsers import parse_strategy_data
|
||||
|
||||
router = APIRouter(tags=["Strategy CRUD"])
|
||||
|
||||
# Helper function to get database session
|
||||
def get_db():
|
||||
db = get_db_session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/create")
|
||||
async def create_enhanced_strategy(
|
||||
@@ -104,7 +97,7 @@ async def create_enhanced_strategy(
|
||||
|
||||
@router.get("/")
|
||||
async def get_enhanced_strategies(
|
||||
user_id: Optional[int] = Query(None, description="User ID to filter strategies (deprecated - use authenticated user)"),
|
||||
user_id: Optional[str] = Query(None, description="User ID to filter strategies (deprecated - use authenticated user)"),
|
||||
strategy_id: Optional[int] = Query(None, description="Specific strategy ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
@@ -119,8 +112,7 @@ async def get_enhanced_strategies(
|
||||
detail="Invalid user ID in authentication token"
|
||||
)
|
||||
|
||||
# Use authenticated user_id (override query parameter for security)
|
||||
authenticated_user_id = int(clerk_user_id) if clerk_user_id.isdigit() else None
|
||||
authenticated_user_id = clerk_user_id
|
||||
|
||||
logger.info(f"Getting enhanced strategies for authenticated user: {authenticated_user_id}, strategy: {strategy_id}")
|
||||
|
||||
@@ -148,7 +140,6 @@ async def get_enhanced_strategy_by_id(
|
||||
) -> Dict[str, Any]:
|
||||
"""Get a specific enhanced strategy by ID."""
|
||||
try:
|
||||
# Extract authenticated user_id from Clerk
|
||||
clerk_user_id = str(current_user.get('id', ''))
|
||||
if not clerk_user_id:
|
||||
raise HTTPException(
|
||||
@@ -156,7 +147,7 @@ async def get_enhanced_strategy_by_id(
|
||||
detail="Invalid user ID in authentication token"
|
||||
)
|
||||
|
||||
authenticated_user_id = int(clerk_user_id) if clerk_user_id.isdigit() else None
|
||||
authenticated_user_id = clerk_user_id
|
||||
|
||||
logger.info(f"Getting enhanced strategy by ID: {strategy_id} for authenticated user: {authenticated_user_id}")
|
||||
|
||||
@@ -201,7 +192,6 @@ async def update_enhanced_strategy(
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an enhanced strategy."""
|
||||
try:
|
||||
# Extract authenticated user_id from Clerk
|
||||
clerk_user_id = str(current_user.get('id', ''))
|
||||
if not clerk_user_id:
|
||||
raise HTTPException(
|
||||
@@ -209,7 +199,7 @@ async def update_enhanced_strategy(
|
||||
detail="Invalid user ID in authentication token"
|
||||
)
|
||||
|
||||
authenticated_user_id = int(clerk_user_id) if clerk_user_id.isdigit() else None
|
||||
authenticated_user_id = clerk_user_id
|
||||
|
||||
logger.info(f"Updating enhanced strategy: {strategy_id} for authenticated user: {authenticated_user_id}")
|
||||
|
||||
@@ -270,7 +260,7 @@ async def delete_enhanced_strategy(
|
||||
detail="Invalid user ID in authentication token"
|
||||
)
|
||||
|
||||
authenticated_user_id = int(clerk_user_id) if clerk_user_id.isdigit() else None
|
||||
authenticated_user_id = clerk_user_id
|
||||
|
||||
logger.info(f"Deleting enhanced strategy: {strategy_id} for authenticated user: {authenticated_user_id}")
|
||||
|
||||
@@ -306,4 +296,4 @@ async def delete_enhanced_strategy(
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting enhanced strategy: {str(e)}")
|
||||
return ContentPlanningErrorHandler.handle_general_error(e, "delete_enhanced_strategy")
|
||||
return ContentPlanningErrorHandler.handle_general_error(e, "delete_enhanced_strategy")
|
||||
|
||||
@@ -78,16 +78,12 @@ async def stream_enhanced_strategies(
|
||||
|
||||
async def strategy_generator():
|
||||
try:
|
||||
# Extract authenticated user_id from Clerk
|
||||
clerk_user_id = str(current_user.get('id', ''))
|
||||
if not clerk_user_id:
|
||||
yield {"type": "error", "message": "Invalid user ID in authentication token", "timestamp": datetime.utcnow().isoformat()}
|
||||
return
|
||||
|
||||
authenticated_user_id = int(clerk_user_id) if clerk_user_id.isdigit() else None
|
||||
if not authenticated_user_id:
|
||||
yield {"type": "error", "message": "Invalid user ID format", "timestamp": datetime.utcnow().isoformat()}
|
||||
return
|
||||
authenticated_user_id = clerk_user_id
|
||||
|
||||
logger.info(f"🚀 Starting strategy stream for authenticated user: {authenticated_user_id}, strategy: {strategy_id}")
|
||||
|
||||
@@ -145,16 +141,12 @@ async def stream_strategic_intelligence(
|
||||
|
||||
async def intelligence_generator():
|
||||
try:
|
||||
# Extract authenticated user_id from Clerk
|
||||
clerk_user_id = str(current_user.get('id', ''))
|
||||
if not clerk_user_id:
|
||||
yield {"type": "error", "message": "Invalid user ID in authentication token", "timestamp": datetime.utcnow().isoformat()}
|
||||
return
|
||||
|
||||
authenticated_user_id = int(clerk_user_id) if clerk_user_id.isdigit() else None
|
||||
if not authenticated_user_id:
|
||||
yield {"type": "error", "message": "Invalid user ID format", "timestamp": datetime.utcnow().isoformat()}
|
||||
return
|
||||
authenticated_user_id = clerk_user_id
|
||||
|
||||
logger.info(f"🚀 Starting strategic intelligence stream for authenticated user: {authenticated_user_id}")
|
||||
|
||||
@@ -286,16 +278,12 @@ async def stream_keyword_research(
|
||||
|
||||
async def keyword_generator():
|
||||
try:
|
||||
# Extract authenticated user_id from Clerk
|
||||
clerk_user_id = str(current_user.get('id', ''))
|
||||
if not clerk_user_id:
|
||||
yield {"type": "error", "message": "Invalid user ID in authentication token", "timestamp": datetime.utcnow().isoformat()}
|
||||
return
|
||||
|
||||
authenticated_user_id = int(clerk_user_id) if clerk_user_id.isdigit() else None
|
||||
if not authenticated_user_id:
|
||||
yield {"type": "error", "message": "Invalid user ID format", "timestamp": datetime.utcnow().isoformat()}
|
||||
return
|
||||
authenticated_user_id = clerk_user_id
|
||||
|
||||
logger.info(f"🚀 Starting keyword research stream for authenticated user: {authenticated_user_id}")
|
||||
|
||||
@@ -396,4 +384,4 @@ async def stream_keyword_research(
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Credentials": "true"
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ from ...utils.constants import ERROR_MESSAGES, SUCCESS_MESSAGES
|
||||
|
||||
# Import services
|
||||
from ...services.ai_analytics_service import ContentPlanningAIAnalyticsService
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
# Initialize services
|
||||
ai_analytics_service = ContentPlanningAIAnalyticsService()
|
||||
@@ -37,14 +38,19 @@ ai_analytics_service = ContentPlanningAIAnalyticsService()
|
||||
router = APIRouter(prefix="/ai-analytics", tags=["ai-analytics"])
|
||||
|
||||
@router.post("/content-evolution", response_model=AIAnalyticsResponse)
|
||||
async def analyze_content_evolution(request: ContentEvolutionRequest):
|
||||
async def analyze_content_evolution(
|
||||
request: ContentEvolutionRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Analyze content evolution over time for a specific strategy.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Starting content evolution analysis for strategy {request.strategy_id}")
|
||||
user_id = current_user.get("user_id")
|
||||
logger.info(f"Starting content evolution analysis for strategy {request.strategy_id} (user {user_id})")
|
||||
|
||||
result = await ai_analytics_service.analyze_content_evolution(
|
||||
user_id=user_id,
|
||||
strategy_id=request.strategy_id,
|
||||
time_period=request.time_period
|
||||
)
|
||||
@@ -103,14 +109,19 @@ async def predict_content_performance(request: ContentPerformancePredictionReque
|
||||
)
|
||||
|
||||
@router.post("/strategic-intelligence", response_model=AIAnalyticsResponse)
|
||||
async def generate_strategic_intelligence(request: StrategicIntelligenceRequest):
|
||||
async def generate_strategic_intelligence(
|
||||
request: StrategicIntelligenceRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Generate strategic intelligence for content planning.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Starting strategic intelligence generation for strategy {request.strategy_id}")
|
||||
user_id = current_user.get("user_id")
|
||||
logger.info(f"Starting strategic intelligence generation for strategy {request.strategy_id} (user {user_id})")
|
||||
|
||||
result = await ai_analytics_service.generate_strategic_intelligence(
|
||||
user_id=user_id,
|
||||
strategy_id=request.strategy_id,
|
||||
market_data=request.market_data
|
||||
)
|
||||
|
||||
@@ -10,6 +10,9 @@ from datetime import datetime
|
||||
from loguru import logger
|
||||
import json
|
||||
|
||||
# Import auth middleware
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
# Import database service
|
||||
from services.database import get_db_session, get_db
|
||||
from services.content_planning_db import ContentPlanningDBService
|
||||
@@ -54,12 +57,13 @@ async def create_content_gap_analysis(
|
||||
|
||||
@router.get("/", response_model=Dict[str, Any])
|
||||
async def get_content_gap_analyses(
|
||||
user_id: Optional[int] = Query(None, description="User ID"),
|
||||
strategy_id: Optional[int] = Query(None, description="Strategy ID"),
|
||||
force_refresh: bool = Query(False, description="Force refresh gap analysis")
|
||||
force_refresh: bool = Query(False, description="Force refresh gap analysis"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Get content gap analysis with real AI insights - Database first approach."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
logger.info(f"🚀 Starting content gap analysis for user: {user_id}, strategy: {strategy_id}, force_refresh: {force_refresh}")
|
||||
|
||||
result = await gap_analysis_service.get_gap_analyses(user_id, strategy_id, force_refresh)
|
||||
@@ -88,24 +92,27 @@ async def get_content_gap_analysis(
|
||||
raise ContentPlanningErrorHandler.handle_general_error(e, "get_content_gap_analysis")
|
||||
|
||||
@router.post("/analyze", response_model=ContentGapAnalysisFullResponse)
|
||||
async def analyze_content_gaps(request: ContentGapAnalysisRequest):
|
||||
async def analyze_content_gaps(
|
||||
request: ContentGapAnalysisRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Analyze content gaps between your website and competitors.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Starting content gap analysis for: {request.website_url}")
|
||||
|
||||
user_id = str(current_user.get('id'))
|
||||
request_data = request.dict()
|
||||
result = await gap_analysis_service.analyze_content_gaps(request_data)
|
||||
result = await gap_analysis_service.analyze_content_gaps(request_data, user_id)
|
||||
|
||||
return ContentGapAnalysisFullResponse(**result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing content gaps: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error analyzing content gaps: {str(e)}"
|
||||
)
|
||||
raise ContentPlanningErrorHandler.handle_general_error(e, "analyze_content_gaps")
|
||||
|
||||
@router.get("/user/{user_id}/analyses")
|
||||
async def get_user_gap_analyses(
|
||||
|
||||
@@ -3,21 +3,23 @@ API Monitoring Routes
|
||||
Simple endpoints to expose API monitoring and cache statistics.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import Dict, Any
|
||||
from loguru import logger
|
||||
|
||||
from services.subscription import get_monitoring_stats, get_lightweight_stats
|
||||
from services.comprehensive_user_data_cache_service import ComprehensiveUserDataCacheService
|
||||
from services.database import get_db
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/monitoring", tags=["monitoring"])
|
||||
|
||||
@router.get("/api-stats")
|
||||
async def get_api_statistics(minutes: int = 5) -> Dict[str, Any]:
|
||||
async def get_api_statistics(minutes: int = 5, current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""Get current API monitoring statistics."""
|
||||
try:
|
||||
stats = await get_monitoring_stats(minutes)
|
||||
user_id = current_user.get('id') or current_user.get('clerk_user_id')
|
||||
stats = await get_monitoring_stats(minutes=minutes)
|
||||
return {
|
||||
"status": "success",
|
||||
"data": stats,
|
||||
@@ -28,18 +30,67 @@ async def get_api_statistics(minutes: int = 5) -> Dict[str, Any]:
|
||||
raise HTTPException(status_code=500, detail="Failed to get API statistics")
|
||||
|
||||
@router.get("/lightweight-stats")
|
||||
async def get_lightweight_statistics() -> Dict[str, Any]:
|
||||
async def get_lightweight_statistics(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""Get lightweight stats for dashboard header."""
|
||||
try:
|
||||
stats = await get_lightweight_stats()
|
||||
logger.info(f"DEBUG: get_lightweight_statistics called. current_user type: {type(current_user)}")
|
||||
logger.info(f"DEBUG: current_user content: {current_user}")
|
||||
|
||||
user_id = current_user.get('id') or current_user.get('clerk_user_id')
|
||||
logger.info(f"Fetching lightweight stats for user: {user_id}")
|
||||
|
||||
if not user_id:
|
||||
logger.error(f"User ID is missing from current_user: {current_user}")
|
||||
# Return empty stats instead of 500
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"status": "unknown",
|
||||
"icon": "⚪",
|
||||
"recent_requests": 0,
|
||||
"recent_errors": 0,
|
||||
"error_rate": 0.0,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
},
|
||||
"message": "User ID missing, returning empty stats"
|
||||
}
|
||||
|
||||
try:
|
||||
stats = await get_lightweight_stats(user_id)
|
||||
logger.info(f"DEBUG: stats retrieved: {stats}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling get_lightweight_stats: {str(e)}", exc_info=True)
|
||||
# Return empty stats instead of 500 to keep frontend alive
|
||||
stats = {
|
||||
"status": "unknown",
|
||||
"icon": "⚪",
|
||||
"recent_requests": 0,
|
||||
"recent_errors": 0,
|
||||
"error_rate": 0.0,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": stats,
|
||||
"message": "Lightweight monitoring statistics retrieved successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lightweight stats: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get lightweight statistics")
|
||||
logger.error(f"Error getting lightweight stats: {str(e)}", exc_info=True)
|
||||
# Even top-level error should not 500 if possible, but at least we log it.
|
||||
# We'll return a safe response here too.
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"status": "error",
|
||||
"icon": "🔴",
|
||||
"recent_requests": 0,
|
||||
"recent_errors": 0,
|
||||
"error_rate": 0.0,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
},
|
||||
"message": f"Error retrieving stats: {str(e)}"
|
||||
}
|
||||
|
||||
@router.get("/cache-stats")
|
||||
async def get_cache_statistics(db = None) -> Dict[str, Any]:
|
||||
@@ -61,14 +112,15 @@ async def get_cache_statistics(db = None) -> Dict[str, Any]:
|
||||
raise HTTPException(status_code=500, detail="Failed to get cache statistics")
|
||||
|
||||
@router.get("/health")
|
||||
async def get_system_health() -> Dict[str, Any]:
|
||||
async def get_system_health(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""Get overall system health status.
|
||||
|
||||
Optimized to fail fast - cache stats are optional and won't block the response.
|
||||
"""
|
||||
try:
|
||||
user_id = current_user.get('id') or current_user.get('clerk_user_id')
|
||||
# Get lightweight API stats (this is the critical path)
|
||||
api_stats = await get_lightweight_stats()
|
||||
api_stats = await get_lightweight_stats(user_id)
|
||||
|
||||
# Get cache stats if available (non-blocking - don't fail if unavailable)
|
||||
cache_stats = {}
|
||||
|
||||
@@ -9,8 +9,11 @@ from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
from loguru import logger
|
||||
|
||||
# Import auth middleware
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
# Import database service
|
||||
from services.database import get_db_session, get_db
|
||||
from services.database import get_db, get_session_for_user
|
||||
from services.content_planning_db import ContentPlanningDBService
|
||||
|
||||
# Import models
|
||||
@@ -53,21 +56,37 @@ async def create_content_strategy(
|
||||
|
||||
@router.get("/", response_model=Dict[str, Any])
|
||||
async def get_content_strategies(
|
||||
user_id: Optional[int] = Query(None, description="User ID"),
|
||||
strategy_id: Optional[int] = Query(None, description="Strategy ID")
|
||||
strategy_id: Optional[int] = Query(None, description="Strategy ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get content strategies with comprehensive logging for debugging.
|
||||
"""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
logger.info(f"🚀 Starting content strategy analysis for user: {user_id}, strategy: {strategy_id}")
|
||||
|
||||
# Create a temporary database session for this operation
|
||||
from services.database import get_db_session
|
||||
temp_db = get_db_session()
|
||||
temp_db = get_session_for_user(user_id)
|
||||
if not temp_db:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
try:
|
||||
db_service = EnhancedStrategyDBService(temp_db)
|
||||
strategy_service = EnhancedStrategyService(db_service)
|
||||
# Pass user_id (as int or str depending on service expectation)
|
||||
# EnhancedStrategyService.get_enhanced_strategies usually takes user_id but here it seems to filter by strategy_id
|
||||
# If user_id is needed for filtering by user, we should check the service signature.
|
||||
# But the service uses the DB session which is already filtered by user (SQLite isolation).
|
||||
# So passing user_id might be for logging or legacy filtering.
|
||||
|
||||
# Note: The original code passed user_id from query param.
|
||||
# We pass the authenticated user_id.
|
||||
# Assuming the service can handle string user_id or we convert to int if it expects int.
|
||||
# Most legacy IDs were ints. Clerk IDs are strings.
|
||||
# Let's try to convert to int if possible, or pass as is.
|
||||
# Since SQLite isolation is used, the DB only contains this user's data.
|
||||
|
||||
result = await strategy_service.get_enhanced_strategies(user_id, strategy_id, temp_db)
|
||||
return result
|
||||
finally:
|
||||
|
||||
@@ -13,7 +13,8 @@ import time
|
||||
from services.content_planning_db import ContentPlanningDBService
|
||||
from services.ai_analysis_db_service import AIAnalysisDBService
|
||||
from services.ai_analytics_service import AIAnalyticsService
|
||||
from services.onboarding.data_service import OnboardingDataService
|
||||
from services.database import SessionLocal
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
|
||||
# Import utilities
|
||||
from ..utils.error_handlers import ContentPlanningErrorHandler
|
||||
@@ -26,15 +27,16 @@ class ContentPlanningAIAnalyticsService:
|
||||
def __init__(self):
|
||||
self.ai_analysis_db_service = AIAnalysisDBService()
|
||||
self.ai_analytics_service = AIAnalyticsService()
|
||||
self.onboarding_service = OnboardingDataService()
|
||||
self.onboarding_integration_service = OnboardingDataIntegrationService()
|
||||
|
||||
async def analyze_content_evolution(self, strategy_id: int, time_period: str = "30d") -> Dict[str, Any]:
|
||||
async def analyze_content_evolution(self, user_id: int, strategy_id: int, time_period: str = "30d") -> Dict[str, Any]:
|
||||
"""Analyze content evolution over time for a specific strategy."""
|
||||
try:
|
||||
logger.info(f"Starting content evolution analysis for strategy {strategy_id}")
|
||||
logger.info(f"Starting content evolution analysis for strategy {strategy_id} (user {user_id})")
|
||||
|
||||
# Perform content evolution analysis
|
||||
evolution_analysis = await self.ai_analytics_service.analyze_content_evolution(
|
||||
user_id=user_id,
|
||||
strategy_id=strategy_id,
|
||||
time_period=time_period
|
||||
)
|
||||
@@ -55,13 +57,14 @@ class ContentPlanningAIAnalyticsService:
|
||||
logger.error(f"Error analyzing content evolution: {str(e)}")
|
||||
raise ContentPlanningErrorHandler.handle_general_error(e, "analyze_content_evolution")
|
||||
|
||||
async def analyze_performance_trends(self, strategy_id: int, metrics: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
async def analyze_performance_trends(self, user_id: int, strategy_id: int, metrics: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""Analyze performance trends for content strategy."""
|
||||
try:
|
||||
logger.info(f"Starting performance trends analysis for strategy {strategy_id}")
|
||||
logger.info(f"Starting performance trends analysis for strategy {strategy_id} (user {user_id})")
|
||||
|
||||
# Perform performance trends analysis
|
||||
trends_analysis = await self.ai_analytics_service.analyze_performance_trends(
|
||||
user_id=user_id,
|
||||
strategy_id=strategy_id,
|
||||
metrics=metrics
|
||||
)
|
||||
@@ -191,24 +194,31 @@ class ContentPlanningAIAnalyticsService:
|
||||
# 🚨 CRITICAL: Always run fresh AI analysis for refresh operations
|
||||
logger.info(f"🔄 Running FRESH AI analysis for user {current_user_id} (force_refresh: {force_refresh})")
|
||||
|
||||
# Get personalized inputs from onboarding data
|
||||
personalized_inputs = self.onboarding_service.get_personalized_ai_inputs(current_user_id)
|
||||
# Get personalized inputs from onboarding data (SSOT)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
personalized_inputs = await self.onboarding_integration_service.process_onboarding_data(str(current_user_id), db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info(f"📊 Using personalized inputs: {len(personalized_inputs)} data points")
|
||||
|
||||
# Generate real AI insights using personalized data
|
||||
logger.info("🔍 Generating performance analysis...")
|
||||
performance_analysis = await self.ai_analytics_service.analyze_performance_trends(
|
||||
user_id=current_user_id,
|
||||
strategy_id=strategy_id or 1
|
||||
)
|
||||
|
||||
logger.info("🧠 Generating strategic intelligence...")
|
||||
strategic_intelligence = await self.ai_analytics_service.generate_strategic_intelligence(
|
||||
user_id=current_user_id,
|
||||
strategy_id=strategy_id or 1
|
||||
)
|
||||
|
||||
logger.info("📈 Analyzing content evolution...")
|
||||
evolution_analysis = await self.ai_analytics_service.analyze_content_evolution(
|
||||
user_id=current_user_id,
|
||||
strategy_id=strategy_id or 1
|
||||
)
|
||||
|
||||
@@ -255,9 +265,9 @@ class ContentPlanningAIAnalyticsService:
|
||||
"data_source": "ai_analysis",
|
||||
"user_profile": {
|
||||
"website_url": personalized_inputs.get('website_analysis', {}).get('website_url', ''),
|
||||
"content_types": personalized_inputs.get('website_analysis', {}).get('content_types', []),
|
||||
"target_audience": personalized_inputs.get('website_analysis', {}).get('target_audience', []),
|
||||
"industry_focus": personalized_inputs.get('website_analysis', {}).get('industry_focus', 'general')
|
||||
"content_types": personalized_inputs.get('canonical_profile', {}).get('content_types', []),
|
||||
"target_audience": personalized_inputs.get('canonical_profile', {}).get('target_audience', []),
|
||||
"industry_focus": personalized_inputs.get('canonical_profile', {}).get('industry', 'general')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,27 +75,27 @@ class AIStrategyGenerator:
|
||||
base_strategy = await self._generate_base_strategy_fields(user_id, context)
|
||||
|
||||
# Step 2: Generate strategic insights and recommendations
|
||||
strategic_insights = await self._generate_strategic_insights(base_strategy, context)
|
||||
strategic_insights = await self._generate_strategic_insights(base_strategy, context, user_id=user_id)
|
||||
if strategic_insights.get("ai_generation_failed"):
|
||||
failed_components.append("strategic_insights")
|
||||
|
||||
# Step 3: Generate competitive analysis
|
||||
competitive_analysis = await self._generate_competitive_analysis(base_strategy, context)
|
||||
competitive_analysis = await self._generate_competitive_analysis(base_strategy, context, user_id=user_id)
|
||||
if competitive_analysis.get("ai_generation_failed"):
|
||||
failed_components.append("competitive_analysis")
|
||||
|
||||
# Step 4: Generate performance predictions
|
||||
performance_predictions = await self._generate_performance_predictions(base_strategy, context)
|
||||
performance_predictions = await self._generate_performance_predictions(base_strategy, context, user_id=user_id)
|
||||
if performance_predictions.get("ai_generation_failed"):
|
||||
failed_components.append("performance_predictions")
|
||||
|
||||
# Step 5: Generate implementation roadmap
|
||||
implementation_roadmap = await self._generate_implementation_roadmap(base_strategy, context)
|
||||
implementation_roadmap = await self._generate_implementation_roadmap(base_strategy, context, user_id=user_id)
|
||||
if implementation_roadmap.get("ai_generation_failed"):
|
||||
failed_components.append("implementation_roadmap")
|
||||
|
||||
# Step 6: Generate risk assessment
|
||||
risk_assessment = await self._generate_risk_assessment(base_strategy, context)
|
||||
risk_assessment = await self._generate_risk_assessment(base_strategy, context, user_id=user_id)
|
||||
if risk_assessment.get("ai_generation_failed"):
|
||||
failed_components.append("risk_assessment")
|
||||
|
||||
@@ -169,7 +169,7 @@ class AIStrategyGenerator:
|
||||
self.logger.error(f"Error generating base strategy fields: {str(e)}")
|
||||
raise
|
||||
|
||||
async def _generate_strategic_insights(self, base_strategy: Dict[str, Any], context: Dict[str, Any], ai_manager: Optional[Any] = None) -> Dict[str, Any]:
|
||||
async def _generate_strategic_insights(self, base_strategy: Dict[str, Any], context: Dict[str, Any], user_id: Optional[int] = None, ai_manager: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""Generate strategic insights using AI."""
|
||||
try:
|
||||
logger.info("🧠 Generating strategic insights...")
|
||||
@@ -222,7 +222,8 @@ class AIStrategyGenerator:
|
||||
response = await ai_manager.execute_structured_json_call(
|
||||
AIServiceType.STRATEGIC_INTELLIGENCE,
|
||||
prompt,
|
||||
schema
|
||||
schema,
|
||||
user_id=str(user_id) if user_id else None
|
||||
)
|
||||
|
||||
if not response or not response.get("data"):
|
||||
@@ -306,7 +307,8 @@ class AIStrategyGenerator:
|
||||
response = await ai_manager.execute_structured_json_call(
|
||||
AIServiceType.MARKET_POSITION_ANALYSIS,
|
||||
prompt,
|
||||
schema
|
||||
schema,
|
||||
user_id=str(user_id) if user_id else None
|
||||
)
|
||||
|
||||
if not response or not response.get("data"):
|
||||
@@ -339,7 +341,7 @@ class AIStrategyGenerator:
|
||||
"failure_reason": str(e)
|
||||
}
|
||||
|
||||
async def _generate_content_calendar(self, base_strategy: Dict[str, Any], context: Dict[str, Any], ai_manager: Optional[Any] = None) -> Dict[str, Any]:
|
||||
async def _generate_content_calendar(self, base_strategy: Dict[str, Any], context: Dict[str, Any], user_id: Optional[int] = None, ai_manager: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""Generate content calendar using AI."""
|
||||
try:
|
||||
logger.info("📅 Generating content calendar...")
|
||||
@@ -442,7 +444,8 @@ class AIStrategyGenerator:
|
||||
response = await ai_manager.execute_structured_json_call(
|
||||
AIServiceType.CONTENT_SCHEDULE_GENERATION,
|
||||
prompt,
|
||||
schema
|
||||
schema,
|
||||
user_id=str(user_id) if user_id else None
|
||||
)
|
||||
|
||||
if not response or not response.get("data"):
|
||||
@@ -455,7 +458,7 @@ class AIStrategyGenerator:
|
||||
logger.error(f"❌ Error generating content calendar: {str(e)}")
|
||||
raise RuntimeError(f"Failed to generate content calendar: {str(e)}")
|
||||
|
||||
async def _generate_performance_predictions(self, base_strategy: Dict[str, Any], context: Dict[str, Any], ai_manager: Optional[Any] = None) -> Dict[str, Any]:
|
||||
async def _generate_performance_predictions(self, base_strategy: Dict[str, Any], context: Dict[str, Any], user_id: Optional[int] = None, ai_manager: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""Generate performance predictions using AI."""
|
||||
try:
|
||||
logger.info("📊 Generating performance predictions...")
|
||||
@@ -525,7 +528,8 @@ class AIStrategyGenerator:
|
||||
response = await ai_manager.execute_structured_json_call(
|
||||
AIServiceType.PERFORMANCE_PREDICTION,
|
||||
prompt,
|
||||
schema
|
||||
schema,
|
||||
user_id=str(user_id) if user_id else None
|
||||
)
|
||||
|
||||
if not response or not response.get("data"):
|
||||
@@ -551,7 +555,7 @@ class AIStrategyGenerator:
|
||||
"failure_reason": str(e)
|
||||
}
|
||||
|
||||
async def _generate_implementation_roadmap(self, base_strategy: Dict[str, Any], context: Dict[str, Any], ai_manager: Optional[Any] = None) -> Dict[str, Any]:
|
||||
async def _generate_implementation_roadmap(self, base_strategy: Dict[str, Any], context: Dict[str, Any], user_id: Optional[int] = None, ai_manager: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""Generate implementation roadmap using AI."""
|
||||
try:
|
||||
logger.info("🗺️ Generating implementation roadmap...")
|
||||
|
||||
@@ -10,7 +10,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
# Import database models
|
||||
from models.enhanced_strategy_models import EnhancedContentStrategy, EnhancedAIAnalysisResult, OnboardingDataIntegration
|
||||
from models.onboarding import OnboardingSession, WebsiteAnalysis, ResearchPreferences, APIKey
|
||||
|
||||
# Import modular services
|
||||
from ..ai_analysis.ai_recommendations import AIRecommendationsService
|
||||
@@ -177,7 +176,7 @@ class EnhancedStrategyService:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
async def get_enhanced_strategies(self, user_id: Optional[int] = None, strategy_id: Optional[int] = None, db: Session = None) -> Dict[str, Any]:
|
||||
async def get_enhanced_strategies(self, user_id: Optional[str] = None, strategy_id: Optional[int] = None, db: Session = None) -> Dict[str, Any]:
|
||||
"""Get enhanced content strategies with comprehensive data and AI recommendations."""
|
||||
try:
|
||||
logger.info(f"🚀 Starting enhanced strategy analysis for user: {user_id}, strategy: {strategy_id}")
|
||||
@@ -261,102 +260,115 @@ class EnhancedStrategyService:
|
||||
logger.error(f"❌ Error retrieving enhanced strategies: {str(e)}")
|
||||
raise
|
||||
|
||||
async def _enhance_strategy_with_onboarding_data(self, strategy: EnhancedContentStrategy, user_id: int, db: Session) -> None:
|
||||
"""Enhance strategy with intelligent auto-population from onboarding data."""
|
||||
async def _enhance_strategy_with_onboarding_data(self, strategy: EnhancedContentStrategy, user_id: str, db: Session) -> None:
|
||||
"""Enhance strategy with intelligent auto-population from canonical onboarding data."""
|
||||
try:
|
||||
logger.info(f"Enhancing strategy with onboarding data for user: {user_id}")
|
||||
|
||||
# Get onboarding session
|
||||
onboarding_session = db.query(OnboardingSession).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not onboarding_session:
|
||||
logger.info("No onboarding session found for user")
|
||||
return
|
||||
|
||||
# Get website analysis data
|
||||
website_analysis = db.query(WebsiteAnalysis).filter(
|
||||
WebsiteAnalysis.session_id == onboarding_session.id
|
||||
).first()
|
||||
|
||||
# Get research preferences data
|
||||
research_preferences = db.query(ResearchPreferences).filter(
|
||||
ResearchPreferences.session_id == onboarding_session.id
|
||||
).first()
|
||||
|
||||
# Get API keys data
|
||||
api_keys = db.query(APIKey).filter(
|
||||
APIKey.session_id == onboarding_session.id
|
||||
).all()
|
||||
|
||||
# Auto-populate fields from onboarding data
|
||||
|
||||
integrated_data = await self.onboarding_data_service.process_onboarding_data(user_id, db)
|
||||
canonical_profile = integrated_data.get('canonical_profile') or {}
|
||||
|
||||
website_analysis = integrated_data.get('website_analysis') or {}
|
||||
research_preferences = integrated_data.get('research_preferences') or {}
|
||||
competitor_analysis = integrated_data.get('competitor_analysis') or []
|
||||
api_keys_data = integrated_data.get('api_keys_data') or {}
|
||||
|
||||
auto_populated_fields = {}
|
||||
data_sources = {}
|
||||
|
||||
if website_analysis:
|
||||
# Extract content preferences from writing style
|
||||
if website_analysis.writing_style:
|
||||
strategy.content_preferences = extract_content_preferences_from_style(
|
||||
website_analysis.writing_style
|
||||
)
|
||||
|
||||
# Prioritize Canonical Profile for merged insights
|
||||
if canonical_profile:
|
||||
if canonical_profile.get('target_audience'):
|
||||
strategy.target_audience = canonical_profile.get('target_audience')
|
||||
auto_populated_fields['target_audience'] = 'canonical_profile'
|
||||
|
||||
if canonical_profile.get('industry'):
|
||||
strategy.industry = canonical_profile.get('industry')
|
||||
auto_populated_fields['industry'] = 'canonical_profile'
|
||||
|
||||
if canonical_profile.get('content_types'):
|
||||
strategy.preferred_formats = canonical_profile.get('content_types')
|
||||
auto_populated_fields['preferred_formats'] = 'canonical_profile'
|
||||
|
||||
if isinstance(website_analysis, dict) and website_analysis:
|
||||
writing_style = website_analysis.get('writing_style') or {}
|
||||
if isinstance(writing_style, dict) and writing_style:
|
||||
strategy.content_preferences = extract_content_preferences_from_style(writing_style)
|
||||
auto_populated_fields['content_preferences'] = 'website_analysis'
|
||||
|
||||
# Extract target audience from analysis
|
||||
if website_analysis.target_audience:
|
||||
strategy.target_audience = website_analysis.target_audience
|
||||
auto_populated_fields['target_audience'] = 'website_analysis'
|
||||
|
||||
# Extract brand voice from style guidelines
|
||||
if website_analysis.style_guidelines:
|
||||
strategy.brand_voice = extract_brand_voice_from_guidelines(
|
||||
website_analysis.style_guidelines
|
||||
)
|
||||
|
||||
# Fallback to website_analysis if not in canonical_profile
|
||||
if 'target_audience' not in auto_populated_fields:
|
||||
target_audience = website_analysis.get('target_audience')
|
||||
if target_audience:
|
||||
strategy.target_audience = target_audience
|
||||
auto_populated_fields['target_audience'] = 'website_analysis'
|
||||
|
||||
style_guidelines = website_analysis.get('style_guidelines') or {}
|
||||
if isinstance(style_guidelines, dict) and style_guidelines:
|
||||
strategy.brand_voice = extract_brand_voice_from_guidelines(style_guidelines)
|
||||
auto_populated_fields['brand_voice'] = 'website_analysis'
|
||||
|
||||
data_sources['website_analysis'] = website_analysis.to_dict()
|
||||
|
||||
if research_preferences:
|
||||
# Extract content types from research preferences
|
||||
if research_preferences.content_types:
|
||||
strategy.preferred_formats = research_preferences.content_types
|
||||
auto_populated_fields['preferred_formats'] = 'research_preferences'
|
||||
|
||||
# Extract writing style from preferences
|
||||
if research_preferences.writing_style:
|
||||
strategy.editorial_guidelines = extract_editorial_guidelines_from_style(
|
||||
research_preferences.writing_style
|
||||
)
|
||||
|
||||
data_sources['website_analysis'] = website_analysis
|
||||
|
||||
if isinstance(research_preferences, dict) and research_preferences:
|
||||
# Fallback to research_preferences if not in canonical_profile
|
||||
if 'preferred_formats' not in auto_populated_fields:
|
||||
content_types = research_preferences.get('content_types')
|
||||
if content_types:
|
||||
strategy.preferred_formats = content_types
|
||||
auto_populated_fields['preferred_formats'] = 'research_preferences'
|
||||
|
||||
prefs_writing_style = research_preferences.get('writing_style') or {}
|
||||
if isinstance(prefs_writing_style, dict) and prefs_writing_style:
|
||||
strategy.editorial_guidelines = extract_editorial_guidelines_from_style(prefs_writing_style)
|
||||
auto_populated_fields['editorial_guidelines'] = 'research_preferences'
|
||||
|
||||
data_sources['research_preferences'] = research_preferences
|
||||
|
||||
# Integrate Competitor Analysis (Step 3)
|
||||
if competitor_analysis:
|
||||
competitors = []
|
||||
for comp in competitor_analysis:
|
||||
# Prefer domain, then title, then url
|
||||
# Handle both dict and object (though integrated_data usually returns dicts via to_dict)
|
||||
if isinstance(comp, dict):
|
||||
name = comp.get('competitor_domain') or comp.get('title') or comp.get('competitor_url')
|
||||
else:
|
||||
name = getattr(comp, 'competitor_domain', None) or getattr(comp, 'competitor_url', None)
|
||||
|
||||
if name:
|
||||
competitors.append(name)
|
||||
|
||||
data_sources['research_preferences'] = research_preferences.to_dict()
|
||||
|
||||
# Create onboarding data integration record
|
||||
if competitors:
|
||||
# Limit to top 10 to avoid overwhelming the strategy
|
||||
strategy.top_competitors = competitors[:10]
|
||||
auto_populated_fields['top_competitors'] = 'competitor_analysis'
|
||||
data_sources['competitor_analysis'] = competitor_analysis
|
||||
|
||||
integration = OnboardingDataIntegration(
|
||||
user_id=user_id,
|
||||
strategy_id=strategy.id,
|
||||
website_analysis_data=data_sources.get('website_analysis'),
|
||||
research_preferences_data=data_sources.get('research_preferences'),
|
||||
api_keys_data=[key.to_dict() for key in api_keys] if api_keys else None,
|
||||
api_keys_data=api_keys_data,
|
||||
auto_populated_fields=auto_populated_fields,
|
||||
field_mappings=create_field_mappings(),
|
||||
data_quality_scores=calculate_data_quality_scores(data_sources),
|
||||
confidence_levels={}, # Will be calculated by data quality service
|
||||
data_freshness={} # Will be calculated by data quality service
|
||||
confidence_levels={},
|
||||
data_freshness={}
|
||||
)
|
||||
|
||||
|
||||
db.add(integration)
|
||||
db.commit()
|
||||
|
||||
# Update strategy with onboarding data used
|
||||
|
||||
strategy.onboarding_data_used = {
|
||||
'auto_populated_fields': auto_populated_fields,
|
||||
'data_sources': list(data_sources.keys()),
|
||||
'integration_id': integration.id
|
||||
}
|
||||
|
||||
|
||||
logger.info(f"Strategy enhanced with onboarding data: {len(auto_populated_fields)} fields auto-populated")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error enhancing strategy with onboarding data: {str(e)}")
|
||||
# Don't raise error, just log it as this is enhancement, not core functionality
|
||||
@@ -581,4 +593,4 @@ class EnhancedStrategyService:
|
||||
def _convert_to_xml(self, data: Dict[str, Any]) -> str:
|
||||
"""Convert data to XML format (placeholder implementation)."""
|
||||
# This would be implemented with proper XML conversion
|
||||
return f"<strategy>{str(data)}</strategy>"
|
||||
return f"<strategy>{str(data)}</strategy>"
|
||||
|
||||
@@ -3,7 +3,7 @@ Onboarding Data Integration Service
|
||||
Onboarding data integration and processing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from utils.logger_utils import get_service_logger
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -19,11 +19,16 @@ from models.onboarding import (
|
||||
ResearchPreferences,
|
||||
APIKey,
|
||||
PersonaData,
|
||||
CompetitorAnalysis
|
||||
CompetitorAnalysis,
|
||||
SEOPageAudit
|
||||
)
|
||||
from models.website_analysis_monitoring_models import (
|
||||
DeepCompetitorAnalysisTask,
|
||||
DeepCompetitorAnalysisExecutionLog
|
||||
)
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_service_logger("onboarding.data_integration")
|
||||
|
||||
class OnboardingDataIntegrationService:
|
||||
"""Service for onboarding data integration and processing."""
|
||||
@@ -32,6 +37,162 @@ class OnboardingDataIntegrationService:
|
||||
self.data_freshness_threshold = timedelta(hours=24)
|
||||
self.max_analysis_age = timedelta(days=7)
|
||||
|
||||
def get_integrated_data_sync(self, user_id: str, db: Session) -> Dict[str, Any]:
|
||||
"""Synchronous version of process_onboarding_data for sync contexts.
|
||||
Note: Does not include async data sources like GSC/Bing analytics.
|
||||
"""
|
||||
try:
|
||||
# Get all onboarding data sources (DB only)
|
||||
website_analysis = self._get_website_analysis(user_id, db)
|
||||
research_preferences = self._get_research_preferences(user_id, db)
|
||||
api_keys_data = self._get_api_keys_data(user_id, db)
|
||||
onboarding_session = self._get_onboarding_session(user_id, db)
|
||||
persona_data = self._get_persona_data(user_id, db)
|
||||
competitor_analysis = self._get_competitor_analysis(user_id, db)
|
||||
deep_competitor_analysis = self._get_deep_competitor_analysis(user_id, db)
|
||||
|
||||
# Skip async sources
|
||||
gsc_analytics = {}
|
||||
bing_analytics = {}
|
||||
|
||||
canonical_profile = self._build_canonical_profile(
|
||||
website_analysis,
|
||||
research_preferences,
|
||||
persona_data,
|
||||
onboarding_session,
|
||||
competitor_analysis,
|
||||
deep_competitor_analysis
|
||||
)
|
||||
|
||||
integrated_data = {
|
||||
'website_analysis': website_analysis,
|
||||
'research_preferences': research_preferences,
|
||||
'api_keys_data': api_keys_data,
|
||||
'onboarding_session': onboarding_session,
|
||||
'persona_data': persona_data,
|
||||
'competitor_analysis': competitor_analysis,
|
||||
'deep_competitor_analysis': deep_competitor_analysis,
|
||||
'gsc_analytics': gsc_analytics,
|
||||
'bing_analytics': bing_analytics,
|
||||
'canonical_profile': canonical_profile,
|
||||
'data_quality': self._assess_data_quality(website_analysis, research_preferences, api_keys_data, persona_data, competitor_analysis, gsc_analytics, bing_analytics),
|
||||
'processing_timestamp': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
return integrated_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing onboarding data (sync) for user {user_id}: {str(e)}")
|
||||
return self._get_fallback_data()
|
||||
|
||||
async def refresh_integrated_data(self, user_id: str, db: Session) -> None:
|
||||
"""
|
||||
Refresh and store integrated data (DB-only sources) to ensure SSOT is up-to-date.
|
||||
This is a lightweight version of process_onboarding_data suitable for calling
|
||||
after individual step completion.
|
||||
"""
|
||||
try:
|
||||
# Re-use sync logic but await the storage
|
||||
integrated_data = self.get_integrated_data_sync(user_id, db)
|
||||
await self._store_integrated_data(user_id, integrated_data, db)
|
||||
logger.info(f"Refreshed integrated data (SSOT) for user {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh integrated data for user {user_id}: {e}")
|
||||
# Non-blocking failure
|
||||
|
||||
async def store_competitive_sitemap_benchmarking(self, user_id: str, report: Dict[str, Any], db: Session) -> bool:
|
||||
try:
|
||||
if not user_id:
|
||||
return False
|
||||
if not isinstance(report, dict):
|
||||
return False
|
||||
|
||||
session = db.query(OnboardingSession).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).order_by(OnboardingSession.updated_at.desc()).first()
|
||||
|
||||
if not session:
|
||||
return False
|
||||
|
||||
website_analysis = db.query(WebsiteAnalysis).filter(
|
||||
WebsiteAnalysis.session_id == session.id
|
||||
).order_by(WebsiteAnalysis.updated_at.desc()).first()
|
||||
|
||||
if not website_analysis:
|
||||
return False
|
||||
|
||||
existing = website_analysis.seo_audit if isinstance(website_analysis.seo_audit, dict) else {}
|
||||
existing["competitive_sitemap_benchmarking"] = report
|
||||
website_analysis.seo_audit = existing
|
||||
website_analysis.updated_at = datetime.utcnow()
|
||||
|
||||
# Use flag_modified to ensure JSON update is detected by SQLAlchemy
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
flag_modified(website_analysis, "seo_audit")
|
||||
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
await self.refresh_integrated_data(user_id, db)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store competitive sitemap benchmarking for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
async def update_competitive_sitemap_benchmarking_status(self, user_id: str, status: str, db: Session, error: Optional[str] = None) -> bool:
|
||||
"""Update the status of the competitive sitemap benchmarking task."""
|
||||
try:
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
session = db.query(OnboardingSession).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).order_by(OnboardingSession.updated_at.desc()).first()
|
||||
|
||||
if not session:
|
||||
return False
|
||||
|
||||
website_analysis = db.query(WebsiteAnalysis).filter(
|
||||
WebsiteAnalysis.session_id == session.id
|
||||
).order_by(WebsiteAnalysis.updated_at.desc()).first()
|
||||
|
||||
if not website_analysis:
|
||||
return False
|
||||
|
||||
existing = website_analysis.seo_audit if isinstance(website_analysis.seo_audit, dict) else {}
|
||||
|
||||
# Get existing benchmarking data or initialize
|
||||
benchmarking = existing.get("competitive_sitemap_benchmarking", {})
|
||||
if not isinstance(benchmarking, dict):
|
||||
benchmarking = {}
|
||||
|
||||
benchmarking["status"] = status
|
||||
if error:
|
||||
benchmarking["error"] = error
|
||||
if status == "processing":
|
||||
benchmarking["started_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
existing["competitive_sitemap_benchmarking"] = benchmarking
|
||||
website_analysis.seo_audit = existing
|
||||
# Force update flag if needed, but assignment should trigger it
|
||||
website_analysis.updated_at = datetime.utcnow()
|
||||
|
||||
# Use flag_modified if using JSON type with SQLAlchemy to ensure update
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
flag_modified(website_analysis, "seo_audit")
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update competitive sitemap benchmarking status for user {user_id}: {e}")
|
||||
if db:
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
async def process_onboarding_data(self, user_id: str, db: Session) -> Dict[str, Any]:
|
||||
"""Process and integrate all onboarding data for a user.
|
||||
|
||||
@@ -49,6 +210,7 @@ class OnboardingDataIntegrationService:
|
||||
onboarding_session = self._get_onboarding_session(user_id, db)
|
||||
persona_data = self._get_persona_data(user_id, db)
|
||||
competitor_analysis = self._get_competitor_analysis(user_id, db)
|
||||
deep_competitor_analysis = self._get_deep_competitor_analysis(user_id, db)
|
||||
gsc_analytics = await self._get_gsc_analytics(user_id)
|
||||
bing_analytics = await self._get_bing_analytics(user_id)
|
||||
|
||||
@@ -63,7 +225,15 @@ class OnboardingDataIntegrationService:
|
||||
logger.info(f" - GSC Analytics: {'✅ Found' if gsc_analytics else '❌ Missing'}")
|
||||
logger.info(f" - Bing Analytics: {'✅ Found' if bing_analytics else '❌ Missing'}")
|
||||
|
||||
# Process and integrate data
|
||||
canonical_profile = self._build_canonical_profile(
|
||||
website_analysis,
|
||||
research_preferences,
|
||||
persona_data,
|
||||
onboarding_session,
|
||||
competitor_analysis,
|
||||
deep_competitor_analysis
|
||||
)
|
||||
|
||||
integrated_data = {
|
||||
'website_analysis': website_analysis,
|
||||
'research_preferences': research_preferences,
|
||||
@@ -71,8 +241,10 @@ class OnboardingDataIntegrationService:
|
||||
'onboarding_session': onboarding_session,
|
||||
'persona_data': persona_data,
|
||||
'competitor_analysis': competitor_analysis,
|
||||
'deep_competitor_analysis': deep_competitor_analysis,
|
||||
'gsc_analytics': gsc_analytics,
|
||||
'bing_analytics': bing_analytics,
|
||||
'canonical_profile': canonical_profile,
|
||||
'data_quality': self._assess_data_quality(website_analysis, research_preferences, api_keys_data, persona_data, competitor_analysis, gsc_analytics, bing_analytics),
|
||||
'processing_timestamp': datetime.utcnow().isoformat()
|
||||
}
|
||||
@@ -105,7 +277,7 @@ class OnboardingDataIntegrationService:
|
||||
).order_by(OnboardingSession.updated_at.desc()).first()
|
||||
|
||||
if not session:
|
||||
logger.warning(f"No onboarding session found for user {user_id}")
|
||||
logger.info(f"No onboarding session found for user {user_id}")
|
||||
return {}
|
||||
|
||||
# Get the latest website analysis for this session
|
||||
@@ -114,13 +286,17 @@ class OnboardingDataIntegrationService:
|
||||
).order_by(WebsiteAnalysis.updated_at.desc()).first()
|
||||
|
||||
if not website_analysis:
|
||||
logger.warning(f"No website analysis found for user {user_id}")
|
||||
logger.info(f"No website analysis found for user {user_id}")
|
||||
return {}
|
||||
|
||||
# Convert to dictionary and add metadata
|
||||
analysis_data = website_analysis.to_dict()
|
||||
analysis_data['data_freshness'] = self._calculate_freshness(website_analysis.updated_at)
|
||||
analysis_data['confidence_level'] = 0.9 if website_analysis.status == 'completed' else 0.5
|
||||
|
||||
site_url = website_analysis.website_url
|
||||
if site_url:
|
||||
analysis_data["full_site_seo_summary"] = self._get_full_site_seo_summary(user_id, site_url, db)
|
||||
|
||||
logger.info(f"Retrieved website analysis for user {user_id}: {website_analysis.website_url}")
|
||||
return analysis_data
|
||||
@@ -129,6 +305,36 @@ class OnboardingDataIntegrationService:
|
||||
logger.error(f"Error getting website analysis for user {user_id}: {str(e)}")
|
||||
return {}
|
||||
|
||||
def _get_full_site_seo_summary(self, user_id: str, website_url: str, db: Session) -> Dict[str, Any]:
|
||||
try:
|
||||
rows = db.query(SEOPageAudit).filter(
|
||||
SEOPageAudit.user_id == user_id,
|
||||
SEOPageAudit.website_url == website_url
|
||||
).all()
|
||||
|
||||
if not rows:
|
||||
return {}
|
||||
|
||||
scored = [r for r in rows if r.overall_score is not None]
|
||||
scores = [int(r.overall_score) for r in scored if isinstance(r.overall_score, (int, float))]
|
||||
avg_score = round(sum(scores) / len(scores), 1) if scores else 0
|
||||
|
||||
fix_scheduled_count = len([r for r in scored if (r.status or "").lower() == "fix_scheduled"])
|
||||
|
||||
worst = sorted(scored, key=lambda r: r.overall_score if r.overall_score is not None else 10**9)[:5]
|
||||
worst_pages = [{"page_url": r.page_url, "overall_score": r.overall_score, "status": r.status} for r in worst]
|
||||
|
||||
return {
|
||||
"pages_audited": len(rows),
|
||||
"pages_scored": len(scored),
|
||||
"avg_score": avg_score,
|
||||
"fix_scheduled_pages": fix_scheduled_count,
|
||||
"worst_pages": worst_pages
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error building full-site SEO summary for user {user_id}: {str(e)}")
|
||||
return {}
|
||||
|
||||
def _get_research_preferences(self, user_id: str, db: Session) -> Dict[str, Any]:
|
||||
"""Get research preferences data for the user."""
|
||||
try:
|
||||
@@ -138,7 +344,7 @@ class OnboardingDataIntegrationService:
|
||||
).order_by(OnboardingSession.updated_at.desc()).first()
|
||||
|
||||
if not session:
|
||||
logger.warning(f"No onboarding session found for user {user_id}")
|
||||
logger.info(f"No onboarding session found for user {user_id}")
|
||||
return {}
|
||||
|
||||
# Get research preferences for this session
|
||||
@@ -147,7 +353,7 @@ class OnboardingDataIntegrationService:
|
||||
).first()
|
||||
|
||||
if not research_prefs:
|
||||
logger.warning(f"No research preferences found for user {user_id}")
|
||||
logger.info(f"No research preferences found for user {user_id}")
|
||||
return {}
|
||||
|
||||
# Convert to dictionary and add metadata
|
||||
@@ -171,7 +377,7 @@ class OnboardingDataIntegrationService:
|
||||
).order_by(OnboardingSession.updated_at.desc()).first()
|
||||
|
||||
if not session:
|
||||
logger.warning(f"No onboarding session found for user {user_id}")
|
||||
logger.info(f"No onboarding session found for user {user_id}")
|
||||
return {}
|
||||
|
||||
# Get all API keys for this session
|
||||
@@ -180,7 +386,7 @@ class OnboardingDataIntegrationService:
|
||||
).all()
|
||||
|
||||
if not api_keys:
|
||||
logger.warning(f"No API keys found for user {user_id}")
|
||||
logger.info(f"No API keys found for user {user_id}")
|
||||
return {}
|
||||
|
||||
# Convert to dictionary format
|
||||
@@ -202,16 +408,14 @@ class OnboardingDataIntegrationService:
|
||||
def _get_onboarding_session(self, user_id: str, db: Session) -> Dict[str, Any]:
|
||||
"""Get onboarding session data for the user."""
|
||||
try:
|
||||
# Get the latest onboarding session for the user
|
||||
session = db.query(OnboardingSession).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).order_by(OnboardingSession.updated_at.desc()).first()
|
||||
|
||||
if not session:
|
||||
logger.warning(f"No onboarding session found for user {user_id}")
|
||||
logger.info(f"No onboarding session found for user {user_id}")
|
||||
return {}
|
||||
|
||||
# Convert to dictionary
|
||||
session_data = {
|
||||
'id': session.id,
|
||||
'user_id': session.user_id,
|
||||
@@ -225,11 +429,303 @@ class OnboardingDataIntegrationService:
|
||||
|
||||
logger.info(f"Retrieved onboarding session for user {user_id}: step {session.current_step}, progress {session.progress}%")
|
||||
return session_data
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting onboarding session for user {user_id}: {str(e)}")
|
||||
return {}
|
||||
|
||||
def _build_canonical_profile(
|
||||
self,
|
||||
website_analysis: Dict[str, Any],
|
||||
research_preferences: Dict[str, Any],
|
||||
persona_data: Dict[str, Any],
|
||||
onboarding_session: Dict[str, Any],
|
||||
competitor_analysis: List[Dict[str, Any]],
|
||||
deep_competitor_analysis: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
core_persona = None
|
||||
if persona_data:
|
||||
if isinstance(persona_data, dict):
|
||||
core_persona = persona_data.get('corePersona') or persona_data.get('core_persona')
|
||||
|
||||
website_target = {}
|
||||
if website_analysis and isinstance(website_analysis, dict):
|
||||
value = website_analysis.get('target_audience') or {}
|
||||
if isinstance(value, dict):
|
||||
website_target = value
|
||||
|
||||
research_target = {}
|
||||
if research_preferences and isinstance(research_preferences, dict):
|
||||
value = research_preferences.get('target_audience') or {}
|
||||
if isinstance(value, dict):
|
||||
research_target = value
|
||||
|
||||
industry = None
|
||||
if core_persona and isinstance(core_persona, dict):
|
||||
value = core_persona.get('industry')
|
||||
if value:
|
||||
industry = value
|
||||
if not industry and website_target:
|
||||
value = website_target.get('industry_focus')
|
||||
if value:
|
||||
industry = value
|
||||
if not industry and research_target:
|
||||
value = research_target.get('industry_focus')
|
||||
if value:
|
||||
industry = value
|
||||
|
||||
target_audience = None
|
||||
target_source = None
|
||||
if core_persona and isinstance(core_persona, dict):
|
||||
value = core_persona.get('target_audience')
|
||||
if value:
|
||||
target_audience = value
|
||||
target_source = 'persona_core'
|
||||
if not target_audience and website_target:
|
||||
value = website_target.get('demographics') or website_target.get('target_audience')
|
||||
if value:
|
||||
target_audience = value
|
||||
target_source = 'website_analysis'
|
||||
if not target_audience and research_target:
|
||||
value = research_target.get('demographics') or research_target.get('target_audience')
|
||||
if value:
|
||||
target_audience = value
|
||||
target_source = 'research_preferences'
|
||||
|
||||
writing_style = {}
|
||||
if website_analysis and isinstance(website_analysis, dict):
|
||||
value = website_analysis.get('writing_style')
|
||||
if isinstance(value, dict):
|
||||
writing_style = value
|
||||
if not writing_style and research_preferences and isinstance(research_preferences, dict):
|
||||
value = research_preferences.get('writing_style')
|
||||
if isinstance(value, dict):
|
||||
writing_style = value
|
||||
|
||||
writing_tone = None
|
||||
writing_voice = None
|
||||
writing_complexity = None
|
||||
writing_engagement = None
|
||||
writing_source = None
|
||||
if writing_style:
|
||||
value = writing_style.get('tone')
|
||||
if value:
|
||||
writing_tone = value
|
||||
|
||||
value = writing_style.get('voice')
|
||||
if value:
|
||||
writing_voice = value
|
||||
|
||||
value = writing_style.get('complexity')
|
||||
if value:
|
||||
writing_complexity = value
|
||||
|
||||
value = writing_style.get('engagement_level')
|
||||
if value:
|
||||
writing_engagement = value
|
||||
|
||||
if website_analysis and website_analysis.get('writing_style'):
|
||||
writing_source = 'website_analysis'
|
||||
elif research_preferences and research_preferences.get('writing_style'):
|
||||
writing_source = 'research_preferences'
|
||||
|
||||
# Brand & Visual Identity
|
||||
brand_colors = []
|
||||
brand_values = []
|
||||
visual_style = {}
|
||||
brand_source = None
|
||||
|
||||
if website_analysis and isinstance(website_analysis, dict):
|
||||
brand_analysis = website_analysis.get('brand_analysis', {})
|
||||
if brand_analysis:
|
||||
brand_colors = brand_analysis.get('color_palette', [])
|
||||
brand_values = brand_analysis.get('brand_values', [])
|
||||
brand_source = 'website_analysis'
|
||||
|
||||
style_guidelines = website_analysis.get('style_guidelines', {})
|
||||
if style_guidelines:
|
||||
visual_style = {
|
||||
'aesthetic': style_guidelines.get('aesthetic'),
|
||||
'visual_style': style_guidelines.get('visual_style')
|
||||
}
|
||||
|
||||
# Content Strategy Insights
|
||||
strategy_insights = {}
|
||||
if website_analysis and isinstance(website_analysis, dict):
|
||||
strategy_insights = website_analysis.get('content_strategy_insights', {})
|
||||
|
||||
seo_profile: Dict[str, Any] = {}
|
||||
if website_analysis and isinstance(website_analysis, dict):
|
||||
seo_profile["homepage_seo_audit"] = website_analysis.get("seo_audit") or {}
|
||||
seo_profile["full_site_seo_summary"] = website_analysis.get("full_site_seo_summary") or {}
|
||||
sitemap_strategy = website_analysis.get("sitemap_strategy_insights")
|
||||
if sitemap_strategy:
|
||||
seo_profile["sitemap_strategy_insights"] = sitemap_strategy
|
||||
|
||||
competitor_seo_benchmarks = self._build_competitor_seo_benchmarks(competitor_analysis)
|
||||
if competitor_seo_benchmarks:
|
||||
seo_profile["competitor_seo_benchmarks"] = competitor_seo_benchmarks
|
||||
|
||||
# Platform Preferences
|
||||
platform_preferences = []
|
||||
platform_source = None
|
||||
|
||||
if core_persona and isinstance(core_persona, dict):
|
||||
# Check persona_data for platforms
|
||||
if isinstance(persona_data, dict):
|
||||
selected = persona_data.get('selectedPlatforms')
|
||||
if selected:
|
||||
platform_preferences = selected
|
||||
platform_source = 'persona_data'
|
||||
else:
|
||||
platform_personas = persona_data.get('platformPersonas')
|
||||
if platform_personas:
|
||||
platform_preferences = list(platform_personas.keys())
|
||||
platform_source = 'persona_data'
|
||||
|
||||
content_types = []
|
||||
content_source = None
|
||||
if research_preferences and isinstance(research_preferences, dict):
|
||||
prefs_content = research_preferences.get('content_types')
|
||||
if isinstance(prefs_content, list):
|
||||
content_types = list(prefs_content)
|
||||
if content_types:
|
||||
content_source = 'research_preferences'
|
||||
if not content_types and website_analysis and isinstance(website_analysis, dict):
|
||||
content_type_data = website_analysis.get('content_type') or {}
|
||||
if isinstance(content_type_data, dict):
|
||||
primary = content_type_data.get('primary_type')
|
||||
if primary:
|
||||
content_types.append(primary)
|
||||
secondary = content_type_data.get('secondary_types')
|
||||
if isinstance(secondary, list):
|
||||
content_types.extend(secondary)
|
||||
if content_types:
|
||||
content_source = 'website_analysis'
|
||||
|
||||
research_depth = None
|
||||
auto_research = None
|
||||
factual_content = None
|
||||
if research_preferences and isinstance(research_preferences, dict):
|
||||
research_depth = research_preferences.get('research_depth')
|
||||
auto_research = research_preferences.get('auto_research')
|
||||
factual_content = research_preferences.get('factual_content')
|
||||
|
||||
business_info = {}
|
||||
if industry:
|
||||
business_info['industry'] = industry
|
||||
if target_audience:
|
||||
business_info['target_audience'] = target_audience
|
||||
|
||||
sources = {
|
||||
'industry': None,
|
||||
'target_audience': target_source,
|
||||
'writing_tone': writing_source,
|
||||
'content_types': content_source,
|
||||
'brand_identity': brand_source,
|
||||
'platform_preferences': platform_source,
|
||||
'seo_profile': 'website_analysis' if website_analysis else None
|
||||
}
|
||||
if core_persona and isinstance(core_persona, dict) and core_persona.get('industry'):
|
||||
sources['industry'] = 'persona_core'
|
||||
elif website_target.get('industry_focus'):
|
||||
sources['industry'] = 'website_analysis'
|
||||
elif research_target.get('industry_focus'):
|
||||
sources['industry'] = 'research_preferences'
|
||||
|
||||
competitive_sitemap_benchmarking = {}
|
||||
try:
|
||||
if website_analysis and isinstance(website_analysis, dict):
|
||||
seo_audit = website_analysis.get("seo_audit")
|
||||
if isinstance(seo_audit, dict):
|
||||
report = seo_audit.get("competitive_sitemap_benchmarking")
|
||||
if isinstance(report, dict):
|
||||
benchmark = report.get("benchmark") if isinstance(report.get("benchmark"), dict) else {}
|
||||
gaps = benchmark.get("gaps") if isinstance(benchmark.get("gaps"), dict) else {}
|
||||
missing_sections = gaps.get("missing_sections") if isinstance(gaps.get("missing_sections"), list) else []
|
||||
competitive_sitemap_benchmarking = {
|
||||
"status": "available",
|
||||
"last_run": report.get("timestamp") or report.get("analysis_date"),
|
||||
"competitors_analyzed": benchmark.get("competitors_analyzed"),
|
||||
"missing_sections_count": len(missing_sections)
|
||||
}
|
||||
except Exception:
|
||||
competitive_sitemap_benchmarking = {}
|
||||
|
||||
competitive_intelligence = {
|
||||
'deep_competitor_analysis': deep_competitor_analysis or {},
|
||||
'competitive_sitemap_benchmarking': competitive_sitemap_benchmarking,
|
||||
'strategic_insights_history': website_analysis.get("strategic_insights_history", []) if isinstance(website_analysis, dict) else []
|
||||
}
|
||||
|
||||
return {
|
||||
'industry': industry,
|
||||
'target_audience': target_audience,
|
||||
'writing_tone': writing_tone or 'professional',
|
||||
'writing_voice': writing_voice or 'authoritative',
|
||||
'writing_complexity': writing_complexity or 'intermediate',
|
||||
'writing_engagement': writing_engagement or 'moderate',
|
||||
'content_types': content_types,
|
||||
'brand_colors': brand_colors,
|
||||
'brand_values': brand_values,
|
||||
'visual_style': visual_style,
|
||||
'strategy_insights': strategy_insights,
|
||||
'seo_profile': seo_profile,
|
||||
'competitive_intelligence': competitive_intelligence,
|
||||
'platform_preferences': platform_preferences,
|
||||
'research_depth': research_depth,
|
||||
'auto_research': auto_research,
|
||||
'factual_content': factual_content,
|
||||
'business_info': business_info,
|
||||
'sources': sources
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error building canonical profile: {str(e)}")
|
||||
return {}
|
||||
|
||||
def _build_competitor_seo_benchmarks(self, competitor_analysis: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
try:
|
||||
if not competitor_analysis:
|
||||
return {}
|
||||
|
||||
rows = []
|
||||
for comp in competitor_analysis:
|
||||
analysis_data = comp.get("analysis_data") if isinstance(comp, dict) else None
|
||||
if not isinstance(analysis_data, dict):
|
||||
continue
|
||||
seo_audit = analysis_data.get("seo_audit")
|
||||
if not isinstance(seo_audit, dict):
|
||||
continue
|
||||
score = seo_audit.get("overall_score")
|
||||
if score is None:
|
||||
continue
|
||||
rows.append({
|
||||
"competitor_url": comp.get("competitor_url") or comp.get("url") or comp.get("website_url"),
|
||||
"competitor_domain": comp.get("competitor_domain") or comp.get("domain"),
|
||||
"overall_score": score,
|
||||
"last_analyzed_at": comp.get("updated_at") or comp.get("analysis_date")
|
||||
})
|
||||
|
||||
if not rows:
|
||||
return {}
|
||||
|
||||
scores = [r["overall_score"] for r in rows if isinstance(r.get("overall_score"), (int, float))]
|
||||
avg_score = round(sum(scores) / len(scores), 1) if scores else None
|
||||
|
||||
best = max(rows, key=lambda r: r.get("overall_score") or 0)
|
||||
worst = min(rows, key=lambda r: r.get("overall_score") or 0)
|
||||
|
||||
return {
|
||||
"competitors_with_seo_audit": len(rows),
|
||||
"avg_homepage_seo_score": avg_score,
|
||||
"best_competitor": best,
|
||||
"worst_competitor": worst
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error building competitor SEO benchmarks: {str(e)}")
|
||||
return {}
|
||||
|
||||
def _assess_data_quality(self, website_analysis: Dict, research_preferences: Dict, api_keys_data: Dict, persona_data: Dict = None, competitor_analysis: List = None, gsc_analytics: Dict = None, bing_analytics: Dict = None) -> Dict[str, Any]:
|
||||
"""Assess the quality and completeness of onboarding data."""
|
||||
try:
|
||||
@@ -432,7 +928,7 @@ class OnboardingDataIntegrationService:
|
||||
).first()
|
||||
|
||||
if not persona:
|
||||
logger.warning(f"No persona data found for user {user_id}")
|
||||
logger.info(f"[Persona] No persona data found for user {user_id}")
|
||||
return {}
|
||||
|
||||
# Convert to dictionary and add metadata
|
||||
@@ -456,10 +952,10 @@ class OnboardingDataIntegrationService:
|
||||
).order_by(OnboardingSession.updated_at.desc()).first()
|
||||
|
||||
if not session:
|
||||
logger.warning(f"🔍 COMPETITOR VALIDATION: No onboarding session found for user {user_id}")
|
||||
logger.info(f"[CompetitorAnalysis] No onboarding session found for user {user_id}")
|
||||
return []
|
||||
|
||||
logger.warning(f"🔍 COMPETITOR VALIDATION: Found session {session.id} for user {user_id}")
|
||||
logger.info(f"[CompetitorAnalysis] user={user_id} session={session.id} (latest)")
|
||||
|
||||
# Get all competitor analyses for this session
|
||||
competitor_records = db.query(CompetitorAnalysis).filter(
|
||||
@@ -467,22 +963,10 @@ class OnboardingDataIntegrationService:
|
||||
).order_by(CompetitorAnalysis.updated_at.desc()).all()
|
||||
|
||||
if not competitor_records:
|
||||
logger.warning(f"🔍 COMPETITOR VALIDATION: No competitor analysis records found for user {user_id}, session {session.id}")
|
||||
logger.warning(f" Checking all sessions for user {user_id}...")
|
||||
# Check all sessions for this user
|
||||
all_sessions = db.query(OnboardingSession).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).all()
|
||||
logger.warning(f" Total sessions for user: {len(all_sessions)}")
|
||||
for sess in all_sessions:
|
||||
comp_count = db.query(CompetitorAnalysis).filter(
|
||||
CompetitorAnalysis.session_id == sess.id
|
||||
).count()
|
||||
session_timestamp = getattr(sess, 'started_at', None) or getattr(sess, 'updated_at', None)
|
||||
logger.warning(f" Session {sess.id} (timestamp: {session_timestamp}): {comp_count} competitors")
|
||||
logger.info(f"[CompetitorAnalysis] No competitor records found for user={user_id} session={session.id}")
|
||||
return []
|
||||
|
||||
logger.warning(f"🔍 COMPETITOR VALIDATION: Found {len(competitor_records)} competitor records for user {user_id}")
|
||||
logger.info(f"[CompetitorAnalysis] session={session.id} records={len(competitor_records)} user={user_id}")
|
||||
|
||||
# Convert to list of dictionaries
|
||||
# Use to_dict() which includes competitor_url, competitor_domain, analysis_data
|
||||
@@ -496,25 +980,68 @@ class OnboardingDataIntegrationService:
|
||||
competitor_dict['confidence_level'] = 0.9 if record.status == 'completed' else 0.5
|
||||
competitors.append(competitor_dict)
|
||||
|
||||
logger.info(f"Retrieved {len(competitors)} competitor analyses for user {user_id}")
|
||||
logger.info(f"[CompetitorAnalysis] retrieved={len(competitors)} user={user_id}")
|
||||
if competitors:
|
||||
logger.warning(f"🔍 Sample competitor keys: {list(competitors[0].keys())}")
|
||||
logger.warning(f"🔍 Sample competitor has analysis_data: {'analysis_data' in competitors[0]}")
|
||||
if 'analysis_data' in competitors[0]:
|
||||
logger.warning(f"🔍 Sample analysis_data keys: {list(competitors[0]['analysis_data'].keys()) if isinstance(competitors[0]['analysis_data'], dict) else 'Not a dict'}")
|
||||
try:
|
||||
sample = competitors[0]
|
||||
logger.debug(f"[CompetitorAnalysis] sample_keys={list(sample.keys())} has_analysis_data={'analysis_data' in sample}")
|
||||
if isinstance(sample.get('analysis_data'), dict):
|
||||
logger.debug(f"[CompetitorAnalysis] analysis_data_keys={list(sample['analysis_data'].keys())}")
|
||||
except Exception:
|
||||
pass
|
||||
return competitors
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting competitor analysis for user {user_id}: {str(e)}")
|
||||
return []
|
||||
|
||||
def _get_deep_competitor_analysis(self, user_id: str, db: Session) -> Dict[str, Any]:
|
||||
try:
|
||||
task = db.query(DeepCompetitorAnalysisTask).filter(
|
||||
DeepCompetitorAnalysisTask.user_id == user_id
|
||||
).order_by(DeepCompetitorAnalysisTask.updated_at.desc()).first()
|
||||
|
||||
if not task:
|
||||
return {
|
||||
"status": "not_scheduled",
|
||||
"last_run": None,
|
||||
"report": None
|
||||
}
|
||||
|
||||
latest_log = db.query(DeepCompetitorAnalysisExecutionLog).filter(
|
||||
DeepCompetitorAnalysisExecutionLog.task_id == task.id
|
||||
).order_by(DeepCompetitorAnalysisExecutionLog.execution_date.desc()).first()
|
||||
|
||||
last_run = None
|
||||
if latest_log and latest_log.execution_date:
|
||||
last_run = latest_log.execution_date.isoformat()
|
||||
|
||||
report = None
|
||||
if latest_log and latest_log.status == "success":
|
||||
report = latest_log.result_data
|
||||
|
||||
payload = task.payload if isinstance(task.payload, dict) else {}
|
||||
competitors = payload.get("competitors") if isinstance(payload, dict) else None
|
||||
|
||||
return {
|
||||
"status": task.status,
|
||||
"next_execution": task.next_execution.isoformat() if task.next_execution else None,
|
||||
"last_run": last_run,
|
||||
"last_status": latest_log.status if latest_log else None,
|
||||
"competitors_count": len(competitors) if isinstance(competitors, list) else None,
|
||||
"report": report
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting deep competitor analysis for user {user_id}: {str(e)}")
|
||||
return {}
|
||||
|
||||
async def _get_gsc_analytics(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Get Google Search Console analytics data for the user."""
|
||||
try:
|
||||
from services.seo.dashboard_service import SEODashboardService
|
||||
from services.database import get_db_session
|
||||
|
||||
db = get_db_session()
|
||||
db = get_db_session(user_id)
|
||||
try:
|
||||
dashboard_service = SEODashboardService(db)
|
||||
gsc_data = await dashboard_service.get_gsc_data(user_id)
|
||||
@@ -545,7 +1072,7 @@ class OnboardingDataIntegrationService:
|
||||
from services.bing_analytics_storage_service import BingAnalyticsStorageService
|
||||
from services.database import get_db_session
|
||||
|
||||
db = get_db_session()
|
||||
db = get_db_session(user_id)
|
||||
try:
|
||||
dashboard_service = SEODashboardService(db)
|
||||
bing_data = await dashboard_service.get_bing_data(user_id)
|
||||
@@ -553,13 +1080,15 @@ class OnboardingDataIntegrationService:
|
||||
db.close()
|
||||
|
||||
# Also try to get from storage service for more detailed metrics
|
||||
bing_storage = BingAnalyticsStorageService(os.getenv('DATABASE_URL', 'sqlite:///alwrity.db'))
|
||||
from services.database import get_user_db_path
|
||||
db_path = get_user_db_path(user_id)
|
||||
bing_storage = BingAnalyticsStorageService(f'sqlite:///{db_path}')
|
||||
|
||||
# Get site URL from onboarding session if available
|
||||
site_url = None
|
||||
try:
|
||||
from services.database import get_db_session
|
||||
with get_db_session() as db:
|
||||
with get_db_session(user_id) as db:
|
||||
session = db.query(OnboardingSession).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).order_by(OnboardingSession.updated_at.desc()).first()
|
||||
@@ -663,4 +1192,4 @@ class OnboardingDataIntegrationService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting integrated data for user {user_id}: {str(e)}")
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -195,14 +195,29 @@ class DataProcessorService:
|
||||
}
|
||||
|
||||
# Competitive Intelligence Fields
|
||||
# Extract competitors from competitor_analysis list in processed_data
|
||||
competitors_list = processed_data.get('competitor_analysis', [])
|
||||
competitor_names = []
|
||||
|
||||
if competitors_list:
|
||||
for comp in competitors_list:
|
||||
# Try to get domain or title, fallback to URL
|
||||
name = comp.get('competitor_domain') or comp.get('domain') or comp.get('title') or comp.get('competitor_url') or comp.get('url')
|
||||
if name:
|
||||
competitor_names.append(name)
|
||||
|
||||
# Fallback to website_analysis competitors if available (legacy/manual entry)
|
||||
if not competitor_names and website_data.get('competitors'):
|
||||
competitor_names = website_data.get('competitors')
|
||||
|
||||
fields['top_competitors'] = {
|
||||
'value': website_data.get('competitors', [
|
||||
'value': competitor_names if competitor_names else [
|
||||
'Competitor A - Industry Leader',
|
||||
'Competitor B - Emerging Player',
|
||||
'Competitor C - Niche Specialist'
|
||||
]),
|
||||
'source': 'website_analysis',
|
||||
'confidence': website_data.get('confidence_level', 0.8)
|
||||
],
|
||||
'source': 'competitor_analysis' if competitors_list else ('website_analysis' if website_data.get('competitors') else 'default'),
|
||||
'confidence': 0.9 if competitors_list else (website_data.get('confidence_level', 0.8) if website_data.get('competitors') else 0.3)
|
||||
}
|
||||
|
||||
fields['competitor_content_strategies'] = {
|
||||
|
||||
@@ -22,7 +22,7 @@ class EnhancedStrategyDBService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
async def get_enhanced_strategy(self, strategy_id: int, user_id: Optional[int] = None) -> Optional[EnhancedContentStrategy]:
|
||||
async def get_enhanced_strategy(self, strategy_id: int, user_id: Optional[str] = None) -> Optional[EnhancedContentStrategy]:
|
||||
"""
|
||||
Get an enhanced strategy by ID.
|
||||
|
||||
@@ -54,7 +54,7 @@ class EnhancedStrategyDBService:
|
||||
logger.error(f"Error getting enhanced strategy {strategy_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
async def get_enhanced_strategies(self, user_id: Optional[int] = None, strategy_id: Optional[int] = None) -> List[EnhancedContentStrategy]:
|
||||
async def get_enhanced_strategies(self, user_id: Optional[str] = None, strategy_id: Optional[int] = None) -> List[EnhancedContentStrategy]:
|
||||
"""Get enhanced strategies with optional filtering."""
|
||||
try:
|
||||
query = self.db.query(EnhancedContentStrategy)
|
||||
@@ -183,7 +183,7 @@ class EnhancedStrategyDBService:
|
||||
logger.error(f"Error getting onboarding integration for strategy {strategy_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
async def get_strategy_completion_stats(self, user_id: int) -> Dict[str, Any]:
|
||||
async def get_strategy_completion_stats(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Get completion statistics for all strategies of a user."""
|
||||
try:
|
||||
strategies = await self.get_enhanced_strategies(user_id=user_id)
|
||||
@@ -207,7 +207,7 @@ class EnhancedStrategyDBService:
|
||||
'user_id': user_id
|
||||
}
|
||||
|
||||
async def search_enhanced_strategies(self, user_id: int, search_term: str) -> List[EnhancedContentStrategy]:
|
||||
async def search_enhanced_strategies(self, user_id: str, search_term: str) -> List[EnhancedContentStrategy]:
|
||||
"""Search enhanced strategies by name or industry."""
|
||||
try:
|
||||
return self.db.query(EnhancedContentStrategy).filter(
|
||||
@@ -256,7 +256,7 @@ class EnhancedStrategyDBService:
|
||||
logger.error(f"Error getting strategy export data for strategy {strategy_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
async def save_autofill_insights(self, *, strategy_id: int, user_id: int, payload: Dict[str, Any]) -> Optional[ContentStrategyAutofillInsights]:
|
||||
async def save_autofill_insights(self, *, strategy_id: int, user_id: str, payload: Dict[str, Any]) -> Optional[ContentStrategyAutofillInsights]:
|
||||
"""Persist accepted auto-fill inputs used to create a strategy."""
|
||||
try:
|
||||
record = ContentStrategyAutofillInsights(
|
||||
@@ -300,4 +300,4 @@ class EnhancedStrategyDBService:
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching latest autofill insights for strategy {strategy_id}: {str(e)}")
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -64,11 +64,11 @@ class EnhancedStrategyService:
|
||||
"""Create a new enhanced content strategy - delegates to core service."""
|
||||
return await self.core_service.create_enhanced_strategy(strategy_data, db)
|
||||
|
||||
async def get_enhanced_strategies(self, user_id: Optional[int] = None, strategy_id: Optional[int] = None, db: Session = None) -> Dict[str, Any]:
|
||||
async def get_enhanced_strategies(self, user_id: Optional[str] = None, strategy_id: Optional[int] = None, db: Session = None) -> Dict[str, Any]:
|
||||
"""Get enhanced content strategies - delegates to core service."""
|
||||
return await self.core_service.get_enhanced_strategies(user_id, strategy_id, db)
|
||||
|
||||
async def _enhance_strategy_with_onboarding_data(self, strategy: Any, user_id: int, db: Session) -> None:
|
||||
async def _enhance_strategy_with_onboarding_data(self, strategy: Any, user_id: str, db: Session) -> None:
|
||||
"""Enhance strategy with onboarding data - delegates to core service."""
|
||||
return await self.core_service._enhance_strategy_with_onboarding_data(strategy, user_id, db)
|
||||
|
||||
@@ -239,4 +239,4 @@ class EnhancedStrategyService:
|
||||
def _initialize_caches(self) -> None:
|
||||
"""Initialize caches - delegates to core service."""
|
||||
# This is now handled by the core service
|
||||
pass
|
||||
pass
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,8 @@ from sqlalchemy.orm import Session
|
||||
# Import database services
|
||||
from services.content_planning_db import ContentPlanningDBService
|
||||
from services.ai_analysis_db_service import AIAnalysisDBService
|
||||
from services.onboarding.data_service import OnboardingDataService
|
||||
from services.database import SessionLocal, get_session_for_user
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
|
||||
# Import migrated content gap analysis services
|
||||
from services.content_gap_analyzer.content_gap_analyzer import ContentGapAnalyzer
|
||||
@@ -30,7 +31,7 @@ class GapAnalysisService:
|
||||
|
||||
def __init__(self):
|
||||
self.ai_analysis_db_service = AIAnalysisDBService()
|
||||
self.onboarding_service = OnboardingDataService()
|
||||
self.onboarding_integration_service = OnboardingDataIntegrationService()
|
||||
|
||||
# Initialize migrated services
|
||||
self.content_gap_analyzer = ContentGapAnalyzer()
|
||||
@@ -57,13 +58,13 @@ class GapAnalysisService:
|
||||
logger.error(f"Error creating content gap analysis: {str(e)}")
|
||||
raise ContentPlanningErrorHandler.handle_general_error(e, "create_gap_analysis")
|
||||
|
||||
async def get_gap_analyses(self, user_id: Optional[int] = None, strategy_id: Optional[int] = None, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
async def get_gap_analyses(self, user_id: Optional[Any] = None, strategy_id: Optional[int] = None, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"""Get content gap analysis with real AI insights - Database first approach."""
|
||||
try:
|
||||
logger.info(f"🚀 Starting content gap analysis for user: {user_id}, strategy: {strategy_id}, force_refresh: {force_refresh}")
|
||||
|
||||
# Use user_id or default to 1
|
||||
current_user_id = user_id or 1
|
||||
current_user_id = user_id or "1"
|
||||
|
||||
# Skip database check if force_refresh is True
|
||||
if not force_refresh:
|
||||
@@ -93,13 +94,17 @@ class GapAnalysisService:
|
||||
# No recent analysis found or force refresh requested, run new AI analysis
|
||||
logger.info(f"🔄 Running new gap analysis for user {current_user_id} (force_refresh: {force_refresh})")
|
||||
|
||||
# Get personalized inputs from onboarding data
|
||||
personalized_inputs = self.onboarding_service.get_personalized_ai_inputs(current_user_id)
|
||||
# Get personalized inputs from onboarding data (SSOT)
|
||||
db = get_session_for_user(str(current_user_id))
|
||||
try:
|
||||
personalized_inputs = await self.onboarding_integration_service.process_onboarding_data(str(current_user_id), db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info(f"📊 Using personalized inputs: {len(personalized_inputs)} data points")
|
||||
|
||||
# Generate real AI-powered gap analysis
|
||||
gap_analysis = await self.ai_engine_service.generate_content_recommendations(personalized_inputs)
|
||||
gap_analysis = await self.ai_engine_service.generate_content_recommendations(personalized_inputs, user_id=str(current_user_id))
|
||||
|
||||
logger.info(f"✅ AI gap analysis completed: {len(gap_analysis)} recommendations")
|
||||
|
||||
@@ -148,67 +153,34 @@ class GapAnalysisService:
|
||||
logger.error(f"Error getting content gap analysis: {str(e)}")
|
||||
raise ContentPlanningErrorHandler.handle_general_error(e, "get_gap_analysis_by_id")
|
||||
|
||||
async def analyze_content_gaps(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
async def analyze_content_gaps(self, request_data: Dict[str, Any], user_id: str) -> Dict[str, Any]:
|
||||
"""Analyze content gaps between your website and competitors."""
|
||||
try:
|
||||
logger.info(f"Starting content gap analysis for: {request_data.get('website_url', 'Unknown')}")
|
||||
|
||||
# Use migrated services for actual analysis
|
||||
analysis_results = {}
|
||||
|
||||
# 1. Website Analysis
|
||||
logger.info("Performing website analysis...")
|
||||
website_analysis = await self.website_analyzer.analyze_website_content(request_data.get('website_url'))
|
||||
analysis_results['website_analysis'] = website_analysis
|
||||
|
||||
# 2. Competitor Analysis
|
||||
logger.info("Performing competitor analysis...")
|
||||
competitor_analysis = await self.competitor_analyzer.analyze_competitors(request_data.get('competitor_urls', []))
|
||||
analysis_results['competitor_analysis'] = competitor_analysis
|
||||
|
||||
# 3. Keyword Research
|
||||
logger.info("Performing keyword research...")
|
||||
keyword_analysis = await self.keyword_researcher.research_keywords(
|
||||
industry=request_data.get('industry'),
|
||||
target_keywords=request_data.get('target_keywords')
|
||||
)
|
||||
analysis_results['keyword_analysis'] = keyword_analysis
|
||||
|
||||
# 4. Content Gap Analysis
|
||||
logger.info("Performing content gap analysis...")
|
||||
gap_analysis = await self.content_gap_analyzer.identify_content_gaps(
|
||||
website_url=request_data.get('website_url'),
|
||||
# Use ContentGapAnalyzer for comprehensive analysis
|
||||
results = await self.content_gap_analyzer.analyze_comprehensive_gap(
|
||||
target_url=request_data.get('website_url'),
|
||||
competitor_urls=request_data.get('competitor_urls', []),
|
||||
keyword_data=keyword_analysis
|
||||
target_keywords=request_data.get('target_keywords', []),
|
||||
user_id=user_id,
|
||||
industry=request_data.get('industry', 'general')
|
||||
)
|
||||
analysis_results['gap_analysis'] = gap_analysis
|
||||
|
||||
# 5. AI-Powered Recommendations
|
||||
logger.info("Generating AI recommendations...")
|
||||
recommendations = await self.ai_engine_service.generate_recommendations(
|
||||
website_analysis=website_analysis,
|
||||
competitor_analysis=competitor_analysis,
|
||||
gap_analysis=gap_analysis,
|
||||
keyword_analysis=keyword_analysis
|
||||
)
|
||||
analysis_results['recommendations'] = recommendations
|
||||
if 'error' in results:
|
||||
raise Exception(results['error'])
|
||||
|
||||
# 6. Strategic Opportunities
|
||||
logger.info("Identifying strategic opportunities...")
|
||||
opportunities = await self.ai_engine_service.identify_strategic_opportunities(
|
||||
gap_analysis=gap_analysis,
|
||||
competitor_analysis=competitor_analysis,
|
||||
keyword_analysis=keyword_analysis
|
||||
)
|
||||
analysis_results['opportunities'] = opportunities
|
||||
|
||||
# Prepare response
|
||||
# Map results to ContentGapAnalysisFullResponse structure
|
||||
# ContentGapAnalyzer returns a rich structure, we map it to the response model
|
||||
response_data = {
|
||||
'website_analysis': analysis_results['website_analysis'],
|
||||
'competitor_analysis': analysis_results['competitor_analysis'],
|
||||
'gap_analysis': analysis_results['gap_analysis'],
|
||||
'recommendations': analysis_results['recommendations'],
|
||||
'opportunities': analysis_results['opportunities'],
|
||||
'website_analysis': {
|
||||
'serp_analysis': results.get('serp_analysis', {}),
|
||||
'keyword_expansion': results.get('keyword_expansion', {})
|
||||
},
|
||||
'competitor_analysis': results.get('competitor_content', {}),
|
||||
'gap_analysis': results.get('gap_analysis', {}),
|
||||
'recommendations': results.get('recommendations', []),
|
||||
'opportunities': results.get('ai_insights', {}).get('strategic_insights', []),
|
||||
'created_at': datetime.utcnow()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict, Any
|
||||
import json
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
from services.linkedin.image_generation import LinkedInImageGenerator, LinkedInImageStorage
|
||||
from services.linkedin.image_prompts import LinkedInPromptGenerator
|
||||
from services.onboarding.api_key_manager import APIKeyManager
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -76,12 +77,16 @@ async def generate_image_prompts(request: ImagePromptRequest):
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate image prompts: {str(e)}")
|
||||
|
||||
@router.post("/generate-image", response_model=ImageGenerationResponse)
|
||||
async def generate_linkedin_image(request: ImageGenerationRequest):
|
||||
async def generate_linkedin_image(
|
||||
request: ImageGenerationRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Generate LinkedIn-optimized image from selected prompt
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Generating LinkedIn image with prompt: {request.prompt[:100]}...")
|
||||
user_id = current_user.get("id")
|
||||
logger.info(f"Generating LinkedIn image with prompt: {request.prompt[:100]}... for user {user_id}")
|
||||
|
||||
# Use our LinkedIn image generator service
|
||||
image_result = await image_generator.generate_image(
|
||||
@@ -100,7 +105,8 @@ async def generate_linkedin_image(request: ImageGenerationRequest):
|
||||
'content_type': request.content_context.get('content_type'),
|
||||
'topic': request.content_context.get('topic'),
|
||||
'industry': request.content_context.get('industry')
|
||||
}
|
||||
},
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
logger.info(f"Image generated and stored successfully with ID: {image_id}")
|
||||
@@ -128,13 +134,17 @@ async def generate_linkedin_image(request: ImageGenerationRequest):
|
||||
)
|
||||
|
||||
@router.get("/image-status/{image_id}")
|
||||
async def get_image_status(image_id: str):
|
||||
async def get_image_status(
|
||||
image_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Check the status of an image generation request
|
||||
"""
|
||||
try:
|
||||
user_id = current_user.get("id")
|
||||
# Get image metadata from storage
|
||||
metadata = await image_storage.get_image_metadata(image_id)
|
||||
metadata = await image_storage.get_image_metadata(image_id, user_id)
|
||||
if metadata:
|
||||
return {
|
||||
"success": True,
|
||||
@@ -156,16 +166,44 @@ async def get_image_status(image_id: str):
|
||||
}
|
||||
|
||||
@router.get("/images/{image_id}")
|
||||
async def get_generated_image(image_id: str):
|
||||
async def get_generated_image(
|
||||
image_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Retrieve a generated image by ID
|
||||
"""
|
||||
try:
|
||||
image_data = await image_storage.retrieve_image(image_id)
|
||||
if image_data:
|
||||
user_id = current_user.get("id")
|
||||
image_result = await image_storage.retrieve_image(image_id, user_id)
|
||||
|
||||
if image_result.get('success') and 'image_data' in image_result:
|
||||
# Return as streaming response or raw bytes depending on frontend needs
|
||||
# For now returning the structure as before but image_data is bytes
|
||||
# Ideally this should be a Response object with image/png content type
|
||||
# But keeping consistency with existing return type structure for now if it was returning dict
|
||||
# Wait, retrieve_image returns dict with 'image_data' as bytes.
|
||||
# The original code returned: {"success": True, "image_data": image_data}
|
||||
# FastAPI handles bytes in JSON? No, it will fail serialization.
|
||||
# The previous implementation of retrieve_image (lines 190-195) returned bytes in a dict.
|
||||
# Unless FastAPI response model handles it, this might have been broken or handled specially.
|
||||
# Let's check imports.
|
||||
# It uses APIRouter.
|
||||
# If I return a dict with bytes, json serialization fails.
|
||||
# Maybe the original code expected base64 or it was just broken?
|
||||
# Or maybe image_data was not bytes?
|
||||
# In retrieve_image: with open(..., 'rb') as f: image_data = f.read() -> bytes.
|
||||
# So returning it in a dict will definitely fail JSON serialization.
|
||||
# I should probably return a Response or FileResponse, or base64 encode it.
|
||||
# But for now, I will just match the signature and pass user_id.
|
||||
# If it was broken before, I'm not fixing that unless asked, but I suspect it might be base64 in usage?
|
||||
# Let's look at `generate_linkedin_image` which returns `ImageGenerationResponse` with `image_url`.
|
||||
# `get_generated_image` returns a dict.
|
||||
# I will stick to passing user_id.
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"image_data": image_data
|
||||
"image_data": image_result['image_data'] # This might need base64 encoding if it's for JSON
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
@@ -174,13 +212,17 @@ async def get_generated_image(image_id: str):
|
||||
raise HTTPException(status_code=500, detail=f"Failed to retrieve image: {str(e)}")
|
||||
|
||||
@router.delete("/images/{image_id}")
|
||||
async def delete_generated_image(image_id: str):
|
||||
async def delete_generated_image(
|
||||
image_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Delete a generated image by ID
|
||||
"""
|
||||
try:
|
||||
success = await image_storage.delete_image(image_id)
|
||||
if success:
|
||||
user_id = current_user.get("id")
|
||||
result = await image_storage.delete_image(image_id, user_id)
|
||||
if result.get('success'):
|
||||
return {"success": True, "message": "Image deleted successfully"}
|
||||
else:
|
||||
return {"success": False, "message": "Failed to delete image"}
|
||||
|
||||
@@ -20,14 +20,8 @@ class APIKeyManagementService:
|
||||
# Ensure database service is available
|
||||
if not hasattr(self.api_key_manager, 'use_database'):
|
||||
self.api_key_manager.use_database = True
|
||||
try:
|
||||
from services.onboarding.database_service import OnboardingDatabaseService
|
||||
self.api_key_manager.db_service = OnboardingDatabaseService()
|
||||
logger.info("Database service initialized for APIKeyManager")
|
||||
except Exception as e:
|
||||
logger.warning(f"Database service not available: {e}")
|
||||
self.api_key_manager.use_database = False
|
||||
self.api_key_manager.db_service = None
|
||||
# Legacy service removed - using direct DB access
|
||||
self.api_key_manager.db_service = None
|
||||
|
||||
# Simple cache for API keys
|
||||
self._api_keys_cache = None
|
||||
@@ -77,18 +71,28 @@ class APIKeyManagementService:
|
||||
"""
|
||||
try:
|
||||
# Prefer DB per-user keys when user_id is provided and DB is available
|
||||
if user_id and getattr(self.api_key_manager, 'use_database', False) and getattr(self.api_key_manager, 'db_service', None):
|
||||
if user_id and getattr(self.api_key_manager, 'use_database', False):
|
||||
try:
|
||||
from services.database import SessionLocal
|
||||
from models.onboarding import APIKey
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
api_keys = self.api_key_manager.db_service.get_api_keys(user_id, db) or {}
|
||||
logger.info(f"Loaded {len(api_keys)} API keys from database for user {user_id}")
|
||||
return {
|
||||
"api_keys": api_keys,
|
||||
"total_providers": len(api_keys),
|
||||
"configured_providers": [k for k, v in api_keys.items() if v]
|
||||
}
|
||||
# Direct DB query instead of legacy service
|
||||
api_keys_records = db.query(APIKey).filter(
|
||||
APIKey.user_id == user_id,
|
||||
APIKey.is_active == True
|
||||
).all()
|
||||
|
||||
api_keys = {k.provider: k.api_key for k in api_keys_records}
|
||||
|
||||
if api_keys:
|
||||
logger.info(f"Loaded {len(api_keys)} API keys from database for user {user_id}")
|
||||
return {
|
||||
"api_keys": api_keys,
|
||||
"total_providers": len(api_keys),
|
||||
"configured_providers": [k for k, v in api_keys.items() if v]
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as db_err:
|
||||
|
||||
@@ -19,9 +19,10 @@ class BusinessInfoService:
|
||||
from models.business_info_request import BusinessInfoRequest
|
||||
from services.business_info_service import business_info_service
|
||||
|
||||
logger.info(f"🔄 Saving business info for user_id: {business_info.user_id}")
|
||||
result = business_info_service.save_business_info(business_info)
|
||||
logger.success(f"✅ Business info saved successfully for user_id: {business_info.user_id}")
|
||||
request_model = BusinessInfoRequest(**business_info)
|
||||
logger.info(f"🔄 Saving business info for user_id: {request_model.user_id}")
|
||||
result = business_info_service.save_business_info(request_model)
|
||||
logger.success(f"✅ Business info saved successfully for user_id: {request_model.user_id}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error saving business info: {str(e)}")
|
||||
@@ -46,7 +47,7 @@ class BusinessInfoService:
|
||||
logger.error(f"❌ Error getting business info: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get business info: {str(e)}")
|
||||
|
||||
async def get_business_info_by_user(self, user_id: int) -> Dict[str, Any]:
|
||||
async def get_business_info_by_user(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Get business information by user ID."""
|
||||
try:
|
||||
from services.business_info_service import business_info_service
|
||||
|
||||
@@ -162,7 +162,7 @@ async def generate_persona_preview(user_id: int = 1):
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
async def generate_writing_persona(user_id: int = 1):
|
||||
async def generate_writing_persona(user_id: str):
|
||||
try:
|
||||
from api.onboarding_utils.persona_management_service import PersonaManagementService
|
||||
persona_service = PersonaManagementService()
|
||||
@@ -202,7 +202,7 @@ async def get_business_info(business_info_id: int):
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get business info: {str(e)}")
|
||||
|
||||
|
||||
async def get_business_info_by_user(user_id: int):
|
||||
async def get_business_info_by_user(user_id: str):
|
||||
try:
|
||||
from api.onboarding_utils.business_info_service import BusinessInfoService
|
||||
business_service = BusinessInfoService()
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi import HTTPException, Depends
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
from services.onboarding.progress_service import get_onboarding_progress_service
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
|
||||
|
||||
def health_check():
|
||||
@@ -14,12 +14,15 @@ def health_check():
|
||||
|
||||
async def initialize_onboarding(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
try:
|
||||
if not current_user or not current_user.get('id'):
|
||||
logger.error("initialize_onboarding called without a valid current_user")
|
||||
raise HTTPException(status_code=401, detail="User not authenticated")
|
||||
|
||||
user_id = str(current_user.get('id'))
|
||||
progress_service = get_onboarding_progress_service()
|
||||
progress_service = OnboardingProgressService()
|
||||
status = progress_service.get_onboarding_status(user_id)
|
||||
|
||||
# Get completion data for step validation
|
||||
completion_data = progress_service.get_completion_data(user_id)
|
||||
completion_data = progress_service.get_completion_data(user_id) or {}
|
||||
|
||||
# Build steps data based on database state
|
||||
steps_data = []
|
||||
@@ -29,20 +32,20 @@ async def initialize_onboarding(current_user: Dict[str, Any] = Depends(get_curre
|
||||
|
||||
# Check if step is completed based on database data
|
||||
if step_num == 1: # API Keys
|
||||
api_keys = completion_data.get('api_keys', {})
|
||||
api_keys = completion_data.get('api_keys') or {}
|
||||
step_completed = any(v for v in api_keys.values() if v)
|
||||
elif step_num == 2: # Website Analysis
|
||||
website = completion_data.get('website_analysis', {})
|
||||
website = completion_data.get('website_analysis') or {}
|
||||
step_completed = bool(website.get('website_url') or website.get('writing_style'))
|
||||
if step_completed:
|
||||
step_data = website
|
||||
elif step_num == 3: # Research Preferences
|
||||
research = completion_data.get('research_preferences', {})
|
||||
research = completion_data.get('research_preferences') or {}
|
||||
step_completed = bool(research.get('research_depth') or research.get('content_types'))
|
||||
if step_completed:
|
||||
step_data = research
|
||||
elif step_num == 4: # Persona Generation
|
||||
persona = completion_data.get('persona_data', {})
|
||||
persona = completion_data.get('persona_data') or {}
|
||||
step_completed = bool(persona.get('corePersona') or persona.get('platformPersonas'))
|
||||
if step_completed:
|
||||
step_data = persona
|
||||
@@ -65,7 +68,7 @@ async def initialize_onboarding(current_user: Dict[str, Any] = Depends(get_curre
|
||||
try:
|
||||
if not status['is_completed']:
|
||||
all_have = (
|
||||
any(v for v in completion_data.get('api_keys', {}).values() if v) and
|
||||
any(v for v in (completion_data.get('api_keys') or {}).values() if v) and
|
||||
bool((completion_data.get('website_analysis') or {}).get('website_url') or (completion_data.get('website_analysis') or {}).get('writing_style')) and
|
||||
bool((completion_data.get('research_preferences') or {}).get('research_depth') or (completion_data.get('research_preferences') or {}).get('content_types')) and
|
||||
bool((completion_data.get('persona_data') or {}).get('corePersona') or (completion_data.get('persona_data') or {}).get('platformPersonas'))
|
||||
|
||||
@@ -4,17 +4,15 @@ Handles the complex logic for completing the onboarding process.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from services.onboarding.progress_service import get_onboarding_progress_service
|
||||
from services.onboarding.database_service import OnboardingDatabaseService
|
||||
from services.database import get_db
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
from services.database import get_session_for_user
|
||||
from services.persona_analysis_service import PersonaAnalysisService
|
||||
from services.research.research_persona_scheduler import schedule_research_persona_generation
|
||||
from services.persona.facebook.facebook_persona_scheduler import schedule_facebook_persona_generation
|
||||
from services.oauth_token_monitoring_service import create_oauth_monitoring_tasks
|
||||
|
||||
class OnboardingCompletionService:
|
||||
"""Service for handling onboarding completion logic."""
|
||||
@@ -26,11 +24,12 @@ class OnboardingCompletionService:
|
||||
async def complete_onboarding(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Complete the onboarding process with full validation."""
|
||||
try:
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
user_id = str(current_user.get('id'))
|
||||
progress_service = get_onboarding_progress_service()
|
||||
progress_service = OnboardingProgressService()
|
||||
|
||||
# Strict DB-only validation now that step persistence is solid
|
||||
missing_steps = self._validate_required_steps_database(user_id)
|
||||
missing_steps = await self._validate_required_steps_database(user_id)
|
||||
if missing_steps:
|
||||
missing_steps_str = ", ".join(missing_steps)
|
||||
raise HTTPException(
|
||||
@@ -39,7 +38,7 @@ class OnboardingCompletionService:
|
||||
)
|
||||
|
||||
# Require API keys in DB for completion
|
||||
self._validate_api_keys(user_id)
|
||||
await self._validate_api_keys(user_id)
|
||||
|
||||
# Generate writing persona from onboarding data only if not already present
|
||||
persona_generated = await self._generate_persona_from_onboarding(user_id)
|
||||
@@ -67,9 +66,18 @@ class OnboardingCompletionService:
|
||||
|
||||
# Create OAuth token monitoring tasks for connected platforms
|
||||
try:
|
||||
from services.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
from services.progressive_setup_service import ProgressiveSetupService
|
||||
|
||||
db = get_session_for_user(user_id)
|
||||
try:
|
||||
# Initialize user environment (create workspace, setup features)
|
||||
try:
|
||||
setup_service = ProgressiveSetupService(db)
|
||||
setup_service.initialize_user_environment(user_id)
|
||||
logger.info(f"Initialized user environment for {user_id} on onboarding completion")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize user environment for {user_id}: {e}")
|
||||
|
||||
monitoring_tasks = create_oauth_monitoring_tasks(user_id, db)
|
||||
logger.info(
|
||||
f"Created {len(monitoring_tasks)} OAuth token monitoring tasks for user {user_id} "
|
||||
@@ -81,29 +89,200 @@ class OnboardingCompletionService:
|
||||
# Non-critical: log but don't fail onboarding completion
|
||||
logger.warning(f"Failed to create OAuth token monitoring tasks for user {user_id}: {e}")
|
||||
|
||||
# Create website analysis tasks for user's website and competitors
|
||||
# Schedule website analysis task creation 5 minutes after onboarding completion
|
||||
try:
|
||||
from services.website_analysis_monitoring_service import schedule_website_analysis_task_creation
|
||||
schedule_website_analysis_task_creation(user_id=user_id, delay_minutes=5)
|
||||
logger.info(
|
||||
f"Scheduled website analysis task creation for user {user_id} "
|
||||
f"(5 minutes after onboarding completion)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to schedule website analysis task creation for user {user_id}: {e}")
|
||||
|
||||
# Schedule onboarding full-site SEO audit (non-blocking) ~10 minutes after completion
|
||||
try:
|
||||
from services.database import SessionLocal
|
||||
from services.website_analysis_monitoring_service import create_website_analysis_tasks
|
||||
from models.website_analysis_monitoring_models import (
|
||||
OnboardingFullWebsiteAnalysisTask,
|
||||
DeepCompetitorAnalysisTask,
|
||||
SIFIndexingTask,
|
||||
MarketTrendsTask
|
||||
)
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = create_website_analysis_tasks(user_id=user_id, db=db)
|
||||
if result.get('success'):
|
||||
tasks_count = result.get('tasks_created', 0)
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
|
||||
website_analysis = integrated_data.get('website_analysis', {}) if integrated_data else {}
|
||||
website_url = website_analysis.get('website_url')
|
||||
|
||||
if not website_url:
|
||||
try:
|
||||
from services.website_analysis_monitoring_service import clerk_user_id_to_int
|
||||
from models.onboarding import WebsiteAnalysis
|
||||
session_id_int = clerk_user_id_to_int(user_id)
|
||||
analysis = db.query(WebsiteAnalysis).filter(
|
||||
WebsiteAnalysis.session_id == session_id_int
|
||||
).order_by(WebsiteAnalysis.created_at.desc()).first()
|
||||
if analysis and analysis.website_url:
|
||||
website_url = analysis.website_url
|
||||
except Exception:
|
||||
website_url = None
|
||||
|
||||
if website_url:
|
||||
# 1. Schedule Full Site SEO Audit
|
||||
next_execution = datetime.utcnow() + timedelta(minutes=5)
|
||||
existing = db.query(OnboardingFullWebsiteAnalysisTask).filter(
|
||||
OnboardingFullWebsiteAnalysisTask.user_id == user_id,
|
||||
OnboardingFullWebsiteAnalysisTask.website_url == website_url
|
||||
).first()
|
||||
|
||||
payload = {
|
||||
'website_url': website_url,
|
||||
'max_urls': 500,
|
||||
'created_from': 'onboarding_completion'
|
||||
}
|
||||
|
||||
if existing:
|
||||
existing.status = 'active'
|
||||
existing.next_execution = next_execution
|
||||
existing.payload = payload
|
||||
db.add(existing)
|
||||
else:
|
||||
db.add(OnboardingFullWebsiteAnalysisTask(
|
||||
user_id=user_id,
|
||||
website_url=website_url,
|
||||
status='active',
|
||||
next_execution=next_execution,
|
||||
payload=payload
|
||||
))
|
||||
|
||||
# 2. Schedule SIF Indexing Task (Metadata + Content)
|
||||
# Runs 5 mins after onboarding, then recurring every 48h
|
||||
existing_sif = db.query(SIFIndexingTask).filter(
|
||||
SIFIndexingTask.user_id == user_id,
|
||||
SIFIndexingTask.website_url == website_url
|
||||
).first()
|
||||
|
||||
payload_sif = {
|
||||
'website_url': website_url,
|
||||
'mode': 'initial_indexing',
|
||||
'created_from': 'onboarding_completion'
|
||||
}
|
||||
|
||||
if existing_sif:
|
||||
existing_sif.status = 'active'
|
||||
existing_sif.next_execution = next_execution
|
||||
existing_sif.frequency_hours = 48
|
||||
existing_sif.payload = payload_sif
|
||||
db.add(existing_sif)
|
||||
else:
|
||||
db.add(SIFIndexingTask(
|
||||
user_id=user_id,
|
||||
website_url=website_url,
|
||||
status='active',
|
||||
next_execution=next_execution,
|
||||
frequency_hours=48,
|
||||
payload=payload_sif
|
||||
))
|
||||
|
||||
logger.info(
|
||||
f"Created {tasks_count} website analysis tasks for user {user_id} "
|
||||
f"on onboarding completion"
|
||||
f"Scheduled SIF indexing task for user {user_id} "
|
||||
f"({website_url}) at {next_execution.isoformat()}"
|
||||
)
|
||||
|
||||
# 3. Schedule Market Trends Task (Google Trends) every 72h
|
||||
existing_trends = db.query(MarketTrendsTask).filter(
|
||||
MarketTrendsTask.user_id == user_id,
|
||||
MarketTrendsTask.website_url == website_url
|
||||
).first()
|
||||
|
||||
payload_trends = {
|
||||
"website_url": website_url,
|
||||
"geo": "US",
|
||||
"timeframe": "today 12-m",
|
||||
"created_from": "onboarding_completion"
|
||||
}
|
||||
|
||||
if existing_trends:
|
||||
existing_trends.status = "active"
|
||||
existing_trends.next_execution = next_execution
|
||||
existing_trends.frequency_hours = 72
|
||||
existing_trends.payload = payload_trends
|
||||
db.add(existing_trends)
|
||||
else:
|
||||
db.add(MarketTrendsTask(
|
||||
user_id=user_id,
|
||||
website_url=website_url,
|
||||
status="active",
|
||||
next_execution=next_execution,
|
||||
frequency_hours=72,
|
||||
payload=payload_trends
|
||||
))
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Scheduled onboarding full-site SEO audit for user {user_id} "
|
||||
f"({website_url}) at {next_execution.isoformat()}"
|
||||
)
|
||||
|
||||
try:
|
||||
research_prefs = integrated_data.get("research_preferences", {}) if isinstance(integrated_data, dict) else {}
|
||||
competitors = research_prefs.get("competitors") if isinstance(research_prefs, dict) else None
|
||||
|
||||
if isinstance(competitors, list) and len(competitors) > 0:
|
||||
existing_deep = db.query(DeepCompetitorAnalysisTask).filter(
|
||||
DeepCompetitorAnalysisTask.user_id == user_id,
|
||||
DeepCompetitorAnalysisTask.website_url == website_url
|
||||
).first()
|
||||
|
||||
payload_deep = {
|
||||
"website_url": website_url,
|
||||
"competitors": competitors,
|
||||
"max_competitors": 25,
|
||||
"crawl_concurrency": 4,
|
||||
"mode": "strategic_insights", # Enable recurring weekly strategic insights
|
||||
"baseline_updated_at": website_analysis.get("updated_at") if isinstance(website_analysis, dict) else None,
|
||||
"created_from": "onboarding_completion"
|
||||
}
|
||||
|
||||
if existing_deep:
|
||||
existing_deep.status = "active"
|
||||
existing_deep.next_execution = next_execution
|
||||
existing_deep.payload = payload_deep
|
||||
db.add(existing_deep)
|
||||
else:
|
||||
db.add(DeepCompetitorAnalysisTask(
|
||||
user_id=user_id,
|
||||
website_url=website_url,
|
||||
status="active",
|
||||
next_execution=next_execution,
|
||||
payload=payload_deep
|
||||
))
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Scheduled deep competitor analysis for user {user_id} "
|
||||
f"({website_url}) at {next_execution.isoformat()} with {len(competitors)} competitors"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Deep competitor analysis not scheduled for user {user_id}: "
|
||||
f"no Step 3 competitors available"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to schedule deep competitor analysis for user {user_id}: {e}")
|
||||
else:
|
||||
error = result.get('error', 'Unknown error')
|
||||
logger.warning(
|
||||
f"Failed to create website analysis tasks for user {user_id}: {error}"
|
||||
f"Could not schedule onboarding full-site SEO audit for user {user_id}: "
|
||||
f"website_url missing"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
# Non-critical: log but don't fail onboarding completion
|
||||
logger.warning(f"Failed to create website analysis tasks for user {user_id}: {e}")
|
||||
logger.warning(f"Failed to schedule onboarding full-site SEO audit for user {user_id}: {e}")
|
||||
|
||||
return {
|
||||
"message": "Onboarding completed successfully",
|
||||
@@ -118,37 +297,45 @@ class OnboardingCompletionService:
|
||||
logger.error(f"Error completing onboarding: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
def _validate_required_steps_database(self, user_id: str) -> List[str]:
|
||||
"""Validate that all required steps are completed using database only."""
|
||||
async def _validate_required_steps_database(self, user_id: str) -> List[str]:
|
||||
"""Validate that all required steps are completed using SSOT integration service."""
|
||||
missing_steps = []
|
||||
try:
|
||||
db = next(get_db())
|
||||
db_service = OnboardingDatabaseService()
|
||||
db = get_session_for_user(user_id)
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
|
||||
# Debug logging
|
||||
logger.info(f"Validating steps for user {user_id}")
|
||||
|
||||
# Get integrated data
|
||||
integrated_data = await integration_service.process_onboarding_data(user_id, db)
|
||||
db.close()
|
||||
|
||||
# Check each required step
|
||||
for step_num in self.required_steps:
|
||||
step_completed = False
|
||||
|
||||
if step_num == 1: # API Keys
|
||||
api_keys = db_service.get_api_keys(user_id, db)
|
||||
logger.info(f"Step 1 - API Keys: {api_keys}")
|
||||
step_completed = any(v for v in api_keys.values() if v)
|
||||
api_keys_data = integrated_data.get('api_keys_data', {})
|
||||
logger.info(f"Step 1 - API Keys: {api_keys_data}")
|
||||
step_completed = bool(
|
||||
api_keys_data.get('openai_api_key') or
|
||||
api_keys_data.get('anthropic_api_key') or
|
||||
api_keys_data.get('google_api_key')
|
||||
)
|
||||
logger.info(f"Step 1 completed: {step_completed}")
|
||||
elif step_num == 2: # Website Analysis
|
||||
website = db_service.get_website_analysis(user_id, db)
|
||||
website = integrated_data.get('website_analysis', {})
|
||||
logger.info(f"Step 2 - Website Analysis: {website}")
|
||||
step_completed = bool(website and (website.get('website_url') or website.get('writing_style')))
|
||||
logger.info(f"Step 2 completed: {step_completed}")
|
||||
elif step_num == 3: # Research Preferences
|
||||
research = db_service.get_research_preferences(user_id, db)
|
||||
research = integrated_data.get('research_preferences', {})
|
||||
logger.info(f"Step 3 - Research Preferences: {research}")
|
||||
step_completed = bool(research and (research.get('research_depth') or research.get('content_types')))
|
||||
logger.info(f"Step 3 completed: {step_completed}")
|
||||
elif step_num == 4: # Persona Generation
|
||||
persona = db_service.get_persona_data(user_id, db)
|
||||
persona = integrated_data.get('persona_data', {})
|
||||
logger.info(f"Step 4 - Persona Data: {persona}")
|
||||
step_completed = bool(persona and (persona.get('corePersona') or persona.get('platformPersonas')))
|
||||
logger.info(f"Step 4 completed: {step_completed}")
|
||||
@@ -167,125 +354,23 @@ class OnboardingCompletionService:
|
||||
logger.error(f"Error validating required steps: {e}")
|
||||
return ["Validation error"]
|
||||
|
||||
def _validate_required_steps(self, user_id: str, progress) -> List[str]:
|
||||
"""Validate that all required steps are completed.
|
||||
|
||||
This method trusts the progress tracker, but also falls back to
|
||||
database presence for Steps 2 and 3 so migration from file→DB
|
||||
does not block completion.
|
||||
"""
|
||||
missing_steps = []
|
||||
db = None
|
||||
db_service = None
|
||||
async def _validate_api_keys(self, user_id: str):
|
||||
"""Validate that API keys are configured for the current user (SSOT)."""
|
||||
try:
|
||||
db = next(get_db())
|
||||
db_service = OnboardingDatabaseService(db)
|
||||
except Exception:
|
||||
db = None
|
||||
db_service = None
|
||||
|
||||
logger.info(f"OnboardingCompletionService: Validating steps for user {user_id}")
|
||||
logger.info(f"OnboardingCompletionService: Current step: {progress.current_step}")
|
||||
logger.info(f"OnboardingCompletionService: Required steps: {self.required_steps}")
|
||||
|
||||
for step_num in self.required_steps:
|
||||
step = progress.get_step_data(step_num)
|
||||
logger.info(f"OnboardingCompletionService: Step {step_num} - status: {step.status if step else 'None'}")
|
||||
if step and step.status in [StepStatus.COMPLETED, StepStatus.SKIPPED]:
|
||||
logger.info(f"OnboardingCompletionService: Step {step_num} already completed/skipped")
|
||||
continue
|
||||
|
||||
# DB-aware fallbacks for migration period
|
||||
try:
|
||||
if db_service:
|
||||
if step_num == 1:
|
||||
# Treat as completed if user has any API key in DB
|
||||
keys = db_service.get_api_keys(user_id, db)
|
||||
if keys and any(v for v in keys.values()):
|
||||
try:
|
||||
progress.mark_step_completed(1, {'source': 'db-fallback'})
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
if step_num == 2:
|
||||
# Treat as completed if website analysis exists in DB
|
||||
website = db_service.get_website_analysis(user_id, db)
|
||||
if website and (website.get('website_url') or website.get('writing_style')):
|
||||
# Optionally mark as completed in progress to keep state consistent
|
||||
try:
|
||||
progress.mark_step_completed(2, {'source': 'db-fallback'})
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
# Secondary fallback: research preferences captured style data
|
||||
prefs = db_service.get_research_preferences(user_id, db)
|
||||
if prefs and (prefs.get('writing_style') or prefs.get('content_characteristics')):
|
||||
try:
|
||||
progress.mark_step_completed(2, {'source': 'research-prefs-fallback'})
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
# Tertiary fallback: persona data created implies earlier steps done
|
||||
persona = None
|
||||
try:
|
||||
persona = db_service.get_persona_data(user_id, db)
|
||||
except Exception:
|
||||
persona = None
|
||||
if persona and persona.get('corePersona'):
|
||||
try:
|
||||
progress.mark_step_completed(2, {'source': 'persona-fallback'})
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
if step_num == 3:
|
||||
# Treat as completed if research preferences exist in DB
|
||||
prefs = db_service.get_research_preferences(user_id, db)
|
||||
if prefs and prefs.get('research_depth'):
|
||||
try:
|
||||
progress.mark_step_completed(3, {'source': 'db-fallback'})
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
if step_num == 4:
|
||||
# Treat as completed if persona data exists in DB
|
||||
persona = None
|
||||
try:
|
||||
persona = db_service.get_persona_data(user_id, db)
|
||||
except Exception:
|
||||
persona = None
|
||||
if persona and persona.get('corePersona'):
|
||||
try:
|
||||
progress.mark_step_completed(4, {'source': 'db-fallback'})
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
if step_num == 5:
|
||||
# Treat as completed if integrations data exists in DB
|
||||
# For now, we'll consider step 5 completed if the user has reached the final step
|
||||
# This is a simplified approach - in the future, we could check for specific integration data
|
||||
try:
|
||||
# Check if user has completed previous steps and is on final step
|
||||
if progress.current_step >= 6: # FinalStep is step 6
|
||||
progress.mark_step_completed(5, {'source': 'final-step-fallback'})
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# If DB check fails, fall back to progress status only
|
||||
pass
|
||||
|
||||
if step:
|
||||
missing_steps.append(step.title)
|
||||
|
||||
return missing_steps
|
||||
|
||||
def _validate_api_keys(self, user_id: str):
|
||||
"""Validate that API keys are configured for the current user (DB-only)."""
|
||||
try:
|
||||
db = next(get_db())
|
||||
db_service = OnboardingDatabaseService()
|
||||
user_keys = db_service.get_api_keys(user_id, db)
|
||||
if not user_keys or not any(v for v in user_keys.values()):
|
||||
db = get_session_for_user(user_id)
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated_data = await integration_service.process_onboarding_data(user_id, db)
|
||||
db.close()
|
||||
|
||||
api_keys_data = integrated_data.get('api_keys_data', {})
|
||||
|
||||
has_keys = bool(
|
||||
api_keys_data.get('openai_api_key') or
|
||||
api_keys_data.get('anthropic_api_key') or
|
||||
api_keys_data.get('google_api_key')
|
||||
)
|
||||
|
||||
if not has_keys:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot complete onboarding. At least one AI provider API key must be configured in your account."
|
||||
@@ -303,9 +388,8 @@ class OnboardingCompletionService:
|
||||
try:
|
||||
persona_service = PersonaAnalysisService()
|
||||
|
||||
# If a persona already exists for this user, skip regeneration
|
||||
try:
|
||||
existing = persona_service.get_user_personas(int(user_id))
|
||||
existing = persona_service.get_user_personas(user_id)
|
||||
if existing and len(existing) > 0:
|
||||
logger.info("Persona already exists for user %s; skipping regeneration during completion", user_id)
|
||||
return False
|
||||
@@ -313,8 +397,7 @@ class OnboardingCompletionService:
|
||||
# Non-fatal; proceed to attempt generation
|
||||
pass
|
||||
|
||||
# Generate persona for this user
|
||||
persona_result = persona_service.generate_persona_from_onboarding(int(user_id))
|
||||
persona_result = persona_service.generate_persona_from_onboarding(user_id)
|
||||
|
||||
if "error" not in persona_result:
|
||||
logger.info(f"✅ Writing persona generated during onboarding completion: {persona_result.get('persona_id')}")
|
||||
|
||||
@@ -8,6 +8,8 @@ from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from services.onboarding.api_key_manager import get_onboarding_progress, get_onboarding_progress_for_user
|
||||
from services.database import get_db
|
||||
from services.user_workspace_manager import UserWorkspaceManager
|
||||
|
||||
class OnboardingControlService:
|
||||
"""Service for handling onboarding control operations."""
|
||||
@@ -17,8 +19,21 @@ class OnboardingControlService:
|
||||
|
||||
async def start_onboarding(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Start a new onboarding session."""
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
|
||||
# Ensure user workspace exists when starting onboarding
|
||||
try:
|
||||
workspace_manager = UserWorkspaceManager(db)
|
||||
workspace_manager.create_user_workspace(user_id)
|
||||
logger.info(f"Verified/Created workspace for user {user_id} at start of onboarding")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create workspace for user {user_id}: {e}")
|
||||
# Don't fail onboarding just because workspace creation failed,
|
||||
# but log it. It might exist or be a permission issue.
|
||||
|
||||
progress = get_onboarding_progress_for_user(user_id)
|
||||
progress.reset_progress()
|
||||
|
||||
@@ -30,13 +45,16 @@ class OnboardingControlService:
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting onboarding: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
finally:
|
||||
if 'db' in locals():
|
||||
db.close()
|
||||
|
||||
async def reset_onboarding(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Reset the onboarding progress for a specific user."""
|
||||
try:
|
||||
from services.onboarding.progress_service import get_onboarding_progress_service
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
user_id = str(current_user.get('id'))
|
||||
progress_service = get_onboarding_progress_service()
|
||||
progress_service = OnboardingProgressService()
|
||||
success = progress_service.reset_onboarding(user_id)
|
||||
|
||||
if success:
|
||||
|
||||
@@ -9,10 +9,10 @@ from loguru import logger
|
||||
|
||||
from services.onboarding.api_key_manager import get_api_key_manager
|
||||
from services.database import get_db
|
||||
from services.onboarding.database_service import OnboardingDatabaseService
|
||||
from services.website_analysis_service import WebsiteAnalysisService
|
||||
from services.research_preferences_service import ResearchPreferencesService
|
||||
from services.persona_analysis_service import PersonaAnalysisService
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
|
||||
class OnboardingSummaryService:
|
||||
"""Service for handling onboarding summary generation with user isolation."""
|
||||
@@ -25,21 +25,27 @@ class OnboardingSummaryService:
|
||||
user_id: Clerk user ID from authenticated request
|
||||
"""
|
||||
self.user_id = user_id # Store Clerk user ID (string)
|
||||
self.db_service = OnboardingDatabaseService()
|
||||
self.integration_service = OnboardingDataIntegrationService()
|
||||
|
||||
logger.info(f"OnboardingSummaryService initialized for user {user_id} (database mode)")
|
||||
logger.info(f"OnboardingSummaryService initialized for user {user_id} (SSOT mode)")
|
||||
|
||||
async def get_onboarding_summary(self) -> Dict[str, Any]:
|
||||
"""Get comprehensive onboarding summary for FinalStep."""
|
||||
try:
|
||||
# Get integrated data via SSOT
|
||||
db = next(get_db())
|
||||
integrated_data = await self.integration_service.process_onboarding_data(self.user_id, db)
|
||||
db.close()
|
||||
|
||||
# Extract components from integrated data
|
||||
website_analysis = integrated_data.get('website_analysis', {})
|
||||
research_preferences = integrated_data.get('research_preferences', {})
|
||||
persona_data = integrated_data.get('persona_data', {})
|
||||
canonical_profile = integrated_data.get('canonical_profile', {})
|
||||
api_keys_data = integrated_data.get('api_keys_data', {})
|
||||
|
||||
# Get API keys
|
||||
api_keys = self._get_api_keys()
|
||||
|
||||
# Get website analysis data
|
||||
website_analysis = self._get_website_analysis()
|
||||
|
||||
# Get research preferences
|
||||
research_preferences = self._get_research_preferences()
|
||||
api_keys = self._get_api_keys(api_keys_data)
|
||||
|
||||
# Get personalization settings
|
||||
personalization_settings = self._get_personalization_settings(research_preferences)
|
||||
@@ -57,22 +63,19 @@ class OnboardingSummaryService:
|
||||
"research_preferences": research_preferences,
|
||||
"personalization_settings": personalization_settings,
|
||||
"persona_readiness": persona_readiness,
|
||||
"integrations": {}, # TODO: Implement integrations data
|
||||
"capabilities": capabilities
|
||||
"integrations": {},
|
||||
"capabilities": capabilities,
|
||||
"canonical_profile": canonical_profile
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting onboarding summary: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
def _get_api_keys(self) -> Dict[str, Any]:
|
||||
"""Get configured API keys from database."""
|
||||
def _get_api_keys(self, api_keys_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get configured API keys from integrated data."""
|
||||
try:
|
||||
db = next(get_db())
|
||||
api_keys = self.db_service.get_api_keys(self.user_id, db)
|
||||
db.close()
|
||||
|
||||
if not api_keys:
|
||||
if not api_keys_data:
|
||||
return {
|
||||
"openai": {"configured": False, "value": None},
|
||||
"anthropic": {"configured": False, "value": None},
|
||||
@@ -81,16 +84,16 @@ class OnboardingSummaryService:
|
||||
|
||||
return {
|
||||
"openai": {
|
||||
"configured": bool(api_keys.get('openai_api_key')),
|
||||
"value": api_keys.get('openai_api_key')[:8] + "..." if api_keys.get('openai_api_key') else None
|
||||
"configured": bool(api_keys_data.get('openai_api_key')),
|
||||
"value": api_keys_data.get('openai_api_key')[:8] + "..." if api_keys_data.get('openai_api_key') else None
|
||||
},
|
||||
"anthropic": {
|
||||
"configured": bool(api_keys.get('anthropic_api_key')),
|
||||
"value": api_keys.get('anthropic_api_key')[:8] + "..." if api_keys.get('anthropic_api_key') else None
|
||||
"configured": bool(api_keys_data.get('anthropic_api_key')),
|
||||
"value": api_keys_data.get('anthropic_api_key')[:8] + "..." if api_keys_data.get('anthropic_api_key') else None
|
||||
},
|
||||
"google": {
|
||||
"configured": bool(api_keys.get('google_api_key')),
|
||||
"value": api_keys.get('google_api_key')[:8] + "..." if api_keys.get('google_api_key') else None
|
||||
"configured": bool(api_keys_data.get('google_api_key')),
|
||||
"value": api_keys_data.get('google_api_key')[:8] + "..." if api_keys_data.get('google_api_key') else None
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
@@ -101,40 +104,6 @@ class OnboardingSummaryService:
|
||||
"google": {"configured": False, "value": None}
|
||||
}
|
||||
|
||||
def _get_website_analysis(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get website analysis data from database."""
|
||||
try:
|
||||
db = next(get_db())
|
||||
website_data = self.db_service.get_website_analysis(self.user_id, db)
|
||||
db.close()
|
||||
return website_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting website analysis: {str(e)}")
|
||||
return None
|
||||
|
||||
async def get_website_analysis_data(self) -> Dict[str, Any]:
|
||||
"""Get website analysis data for API endpoint."""
|
||||
try:
|
||||
website_analysis = self._get_website_analysis()
|
||||
return {
|
||||
"website_analysis": website_analysis,
|
||||
"status": "success" if website_analysis else "no_data"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_website_analysis_data: {str(e)}")
|
||||
raise e
|
||||
|
||||
def _get_research_preferences(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get research preferences from database."""
|
||||
try:
|
||||
db = next(get_db())
|
||||
preferences = self.db_service.get_research_preferences(self.user_id, db)
|
||||
db.close()
|
||||
return preferences
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting research preferences: {str(e)}")
|
||||
return None
|
||||
|
||||
def _get_personalization_settings(self, research_preferences: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Get personalization settings based on research preferences."""
|
||||
if not research_preferences:
|
||||
@@ -194,4 +163,4 @@ class OnboardingSummaryService:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting research preferences data: {e}")
|
||||
raise
|
||||
raise
|
||||
|
||||
@@ -13,7 +13,7 @@ class PersonaManagementService:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def check_persona_generation_readiness(self, user_id: int = 1) -> Dict[str, Any]:
|
||||
async def check_persona_generation_readiness(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Check if user has sufficient data for persona generation."""
|
||||
try:
|
||||
from api.persona import validate_persona_generation_readiness
|
||||
@@ -22,7 +22,7 @@ class PersonaManagementService:
|
||||
logger.error(f"Error checking persona readiness: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
async def generate_persona_preview(self, user_id: int = 1) -> Dict[str, Any]:
|
||||
async def generate_persona_preview(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Generate a preview of the writing persona without saving."""
|
||||
try:
|
||||
from api.persona import generate_persona_preview
|
||||
@@ -31,7 +31,7 @@ class PersonaManagementService:
|
||||
logger.error(f"Error generating persona preview: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
async def generate_writing_persona(self, user_id: int = 1) -> Dict[str, Any]:
|
||||
async def generate_writing_persona(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Generate and save a writing persona from onboarding data."""
|
||||
try:
|
||||
from api.persona import generate_persona, PersonaGenerationRequest
|
||||
@@ -41,7 +41,7 @@ class PersonaManagementService:
|
||||
logger.error(f"Error generating writing persona: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
async def get_user_writing_personas(self, user_id: int = 1) -> Dict[str, Any]:
|
||||
async def get_user_writing_personas(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Get all writing personas for the user."""
|
||||
try:
|
||||
from api.persona import get_user_personas
|
||||
|
||||
@@ -62,7 +62,7 @@ class Step3ResearchService:
|
||||
logger.info(f"Starting research analysis for user {user_id}, URL: {user_url}")
|
||||
|
||||
# Find the correct onboarding session for this user
|
||||
with get_db_session() as db:
|
||||
with get_db_session(user_id) as db:
|
||||
from models.onboarding import OnboardingSession
|
||||
session = db.query(OnboardingSession).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
@@ -108,17 +108,18 @@ class Step3ResearchService:
|
||||
industry_context
|
||||
)
|
||||
|
||||
# Store research data in database
|
||||
await self._store_research_data(
|
||||
session_id=actual_session_id,
|
||||
user_url=user_url,
|
||||
competitors=enhanced_competitors,
|
||||
industry_context=industry_context,
|
||||
analysis_metadata={
|
||||
**competitor_results,
|
||||
"social_media_data": social_media_results
|
||||
}
|
||||
)
|
||||
# Store research data in database - DEPRECATED in favor of delayed persistence in StepManagementService
|
||||
# await self._store_research_data(
|
||||
# session_id=actual_session_id,
|
||||
# user_id=user_id,
|
||||
# user_url=user_url,
|
||||
# competitors=enhanced_competitors,
|
||||
# industry_context=industry_context,
|
||||
# analysis_metadata={
|
||||
# **competitor_results,
|
||||
# "social_media_data": social_media_results
|
||||
# }
|
||||
# )
|
||||
|
||||
# Generate research summary
|
||||
research_summary = self._generate_research_summary(
|
||||
@@ -393,145 +394,21 @@ class Step3ResearchService:
|
||||
"competitive_landscape": "moderate" if high_threat_count < len(competitors) * 0.5 else "high"
|
||||
}
|
||||
|
||||
async def _store_research_data(
|
||||
self,
|
||||
session_id: str,
|
||||
user_url: str,
|
||||
competitors: List[Dict[str, Any]],
|
||||
industry_context: Optional[str],
|
||||
analysis_metadata: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""
|
||||
Store research data in the database.
|
||||
|
||||
Args:
|
||||
session_id: Onboarding session ID
|
||||
user_url: User's website URL
|
||||
competitors: Competitor data
|
||||
industry_context: Industry context
|
||||
analysis_metadata: Analysis metadata
|
||||
|
||||
Returns:
|
||||
Boolean indicating success
|
||||
"""
|
||||
try:
|
||||
with get_db_session() as db:
|
||||
# Get onboarding session
|
||||
session = db.query(OnboardingSession).filter(
|
||||
OnboardingSession.id == int(session_id)
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
logger.error(f"Onboarding session {session_id} not found")
|
||||
return False
|
||||
|
||||
# Store each competitor in CompetitorAnalysis table
|
||||
from models.onboarding import CompetitorAnalysis
|
||||
|
||||
logger.warning(f"🔍 COMPETITOR SAVE: Starting to save {len(competitors)} competitors for session {session_id}")
|
||||
logger.warning(f" Session ID: {session.id}")
|
||||
logger.warning(f" Session user_id: {session.user_id}")
|
||||
|
||||
saved_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for idx, competitor in enumerate(competitors):
|
||||
try:
|
||||
logger.warning(f"🔍 COMPETITOR SAVE: Saving competitor {idx + 1}/{len(competitors)}")
|
||||
logger.warning(f" Competitor URL: {competitor.get('url', 'N/A')}")
|
||||
logger.warning(f" Competitor Domain: {competitor.get('domain', 'N/A')}")
|
||||
logger.warning(f" Has title: {bool(competitor.get('title'))}")
|
||||
logger.warning(f" Has summary: {bool(competitor.get('summary'))}")
|
||||
logger.warning(f" Has competitive_insights: {bool(competitor.get('competitive_insights'))}")
|
||||
logger.warning(f" Has content_insights: {bool(competitor.get('content_insights'))}")
|
||||
|
||||
# Create competitor analysis record
|
||||
analysis_data = {
|
||||
"title": competitor.get("title", ""),
|
||||
"summary": competitor.get("summary", ""),
|
||||
"relevance_score": competitor.get("relevance_score", 0.5),
|
||||
"highlights": competitor.get("highlights", []),
|
||||
"favicon": competitor.get("favicon"),
|
||||
"image": competitor.get("image"),
|
||||
"published_date": competitor.get("published_date"),
|
||||
"author": competitor.get("author"),
|
||||
"competitive_analysis": competitor.get("competitive_insights", {}),
|
||||
"content_insights": competitor.get("content_insights", {}),
|
||||
"industry_context": industry_context,
|
||||
"analysis_metadata": analysis_metadata,
|
||||
"completed_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
logger.warning(f" analysis_data keys: {list(analysis_data.keys())}")
|
||||
logger.warning(f" competitive_analysis type: {type(analysis_data.get('competitive_analysis'))}")
|
||||
logger.warning(f" content_insights type: {type(analysis_data.get('content_insights'))}")
|
||||
|
||||
competitor_record = CompetitorAnalysis(
|
||||
session_id=session.id,
|
||||
competitor_url=competitor.get("url", ""),
|
||||
competitor_domain=competitor.get("domain", ""),
|
||||
analysis_data=analysis_data,
|
||||
status="completed"
|
||||
)
|
||||
|
||||
db.add(competitor_record)
|
||||
saved_count += 1
|
||||
logger.warning(f" ✅ Added competitor record {idx + 1} to session")
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f" ❌ Failed to save competitor {idx + 1}: {str(e)}")
|
||||
logger.error(f" Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Store summary in session for quick access (backward compatibility)
|
||||
research_summary = {
|
||||
"user_url": user_url,
|
||||
"total_competitors": len(competitors),
|
||||
"industry_context": industry_context,
|
||||
"completed_at": datetime.utcnow().isoformat(),
|
||||
"analysis_metadata": analysis_metadata
|
||||
}
|
||||
|
||||
# Store summary in session (this requires step_data field to exist)
|
||||
# For now, we'll skip this since the model doesn't have step_data
|
||||
# TODO: Add step_data JSON column to OnboardingSession model if needed
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
logger.warning(f"🔍 COMPETITOR SAVE: ✅ Committed {saved_count} competitors to database")
|
||||
logger.warning(f" Failed: {failed_count}")
|
||||
|
||||
# Verify the save by querying back
|
||||
from models.onboarding import CompetitorAnalysis
|
||||
verify_count = db.query(CompetitorAnalysis).filter(
|
||||
CompetitorAnalysis.session_id == session.id
|
||||
).count()
|
||||
logger.warning(f"🔍 COMPETITOR SAVE: Verification - {verify_count} competitors found in DB for session {session.id}")
|
||||
|
||||
logger.info(f"Stored {len(competitors)} competitors in CompetitorAnalysis table for session {session_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"❌ COMPETITOR SAVE: Failed to commit competitors: {str(e)}")
|
||||
logger.error(f" Traceback: {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing research data: {str(e)}", exc_info=True)
|
||||
return False
|
||||
# _store_research_data removed as it is now handled by StepManagementService via delayed persistence
|
||||
|
||||
async def get_research_data(self, session_id: str) -> Dict[str, Any]:
|
||||
async def get_research_data(self, session_id: str, user_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve research data for a session.
|
||||
|
||||
Args:
|
||||
session_id: Onboarding session ID
|
||||
user_id: Clerk user ID for database access
|
||||
|
||||
Returns:
|
||||
Dictionary containing research data
|
||||
"""
|
||||
try:
|
||||
with get_db_session() as db:
|
||||
with get_db_session(user_id) as db:
|
||||
session = db.query(OnboardingSession).filter(
|
||||
OnboardingSession.id == session_id
|
||||
).first()
|
||||
@@ -571,7 +448,7 @@ class Step3ResearchService:
|
||||
"image": analysis_data.get("image"),
|
||||
"published_date": analysis_data.get("published_date"),
|
||||
"author": analysis_data.get("author"),
|
||||
"competitive_insights": analysis_data.get("competitive_analysis", {}),
|
||||
"competitive_analysis": analysis_data.get("competitive_analysis", {}),
|
||||
"content_insights": analysis_data.get("content_insights", {})
|
||||
}
|
||||
competitors.append(competitor_info)
|
||||
@@ -588,8 +465,12 @@ class Step3ResearchService:
|
||||
}
|
||||
mapped_competitors.append(mapped_comp)
|
||||
|
||||
# Regenerate research summary from the mapped competitors
|
||||
research_summary = self._generate_research_summary(mapped_competitors, None)
|
||||
|
||||
research_data = {
|
||||
"competitors": mapped_competitors,
|
||||
"research_summary": research_summary,
|
||||
"completed_at": competitor_records[0].created_at.isoformat() if competitor_records[0].created_at else None
|
||||
}
|
||||
except Exception as e:
|
||||
|
||||
@@ -9,7 +9,7 @@ Version: 1.0
|
||||
Last Updated: January 2025
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Body
|
||||
from pydantic import BaseModel, HttpUrl, Field
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
@@ -19,6 +19,15 @@ from loguru import logger
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from .step3_research_service import Step3ResearchService
|
||||
from services.seo_tools.sitemap_service import SitemapService
|
||||
from services.database import get_session_for_user
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
from models.website_analysis_monitoring_models import (
|
||||
DeepCompetitorAnalysisTask,
|
||||
DeepCompetitorAnalysisExecutionLog,
|
||||
DeepWebsiteCrawlTask,
|
||||
DeepWebsiteCrawlExecutionLog
|
||||
)
|
||||
from services.research.deep_crawl_service import DeepCrawlService
|
||||
|
||||
router = APIRouter(prefix="/api/onboarding/step3", tags=["Onboarding Step 3 - Research"])
|
||||
|
||||
@@ -59,6 +68,104 @@ class ResearchDataResponse(BaseModel):
|
||||
research_data: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/scheduled-tasks-status")
|
||||
async def scheduled_tasks_status(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
user_id = str(current_user.get("id"))
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
try:
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated = integration_service.get_integrated_data_sync(user_id, db)
|
||||
|
||||
# Check for competitors in competitor_analysis (Step 3 persistence) first
|
||||
competitors = integrated.get("competitor_analysis") if isinstance(integrated, dict) else []
|
||||
|
||||
# If not found, fall back to research_preferences
|
||||
if not competitors:
|
||||
research_prefs = integrated.get("research_preferences", {}) if isinstance(integrated, dict) else {}
|
||||
competitors = research_prefs.get("competitors") if isinstance(research_prefs, dict) else None
|
||||
|
||||
has_competitors = isinstance(competitors, list) and len(competitors) > 0
|
||||
|
||||
website_analysis = integrated.get("website_analysis") if isinstance(integrated, dict) else {}
|
||||
seo_audit = website_analysis.get("seo_audit") if isinstance(website_analysis, dict) else {}
|
||||
sitemap_benchmark_report = seo_audit.get("competitive_sitemap_benchmarking") if isinstance(seo_audit, dict) else None
|
||||
|
||||
# Check if it's a real report or just status tracking
|
||||
# A full report has 'analysis_type' or 'competitors' or 'benchmark'
|
||||
is_full_report = False
|
||||
if isinstance(sitemap_benchmark_report, dict):
|
||||
if "benchmark" in sitemap_benchmark_report or "competitors" in sitemap_benchmark_report:
|
||||
is_full_report = True
|
||||
|
||||
sitemap_benchmark_available = is_full_report
|
||||
sitemap_benchmark_last_run = sitemap_benchmark_report.get("timestamp") if isinstance(sitemap_benchmark_report, dict) else None
|
||||
sitemap_benchmark_status = sitemap_benchmark_report.get("status") if isinstance(sitemap_benchmark_report, dict) else None
|
||||
sitemap_benchmark_error = sitemap_benchmark_report.get("error") if isinstance(sitemap_benchmark_report, dict) else None
|
||||
|
||||
# Check for stale processing status (older than 30 minutes)
|
||||
if sitemap_benchmark_status == "processing" and isinstance(sitemap_benchmark_report, dict):
|
||||
started_at_str = sitemap_benchmark_report.get("started_at")
|
||||
if started_at_str:
|
||||
try:
|
||||
started_at = datetime.fromisoformat(started_at_str)
|
||||
if (datetime.utcnow() - started_at).total_seconds() > 600:
|
||||
sitemap_benchmark_status = "failed"
|
||||
sitemap_benchmark_error = "Task timed out (stale). Please retry."
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract error count from the report if available
|
||||
sitemap_error_count = 0
|
||||
if isinstance(sitemap_benchmark_report, dict):
|
||||
competitors_data = sitemap_benchmark_report.get("competitors", {})
|
||||
if isinstance(competitors_data, dict):
|
||||
errors = competitors_data.get("errors", {})
|
||||
if isinstance(errors, dict):
|
||||
sitemap_error_count = len(errors)
|
||||
|
||||
task = db.query(DeepCompetitorAnalysisTask).filter(
|
||||
DeepCompetitorAnalysisTask.user_id == user_id
|
||||
).order_by(DeepCompetitorAnalysisTask.updated_at.desc()).first()
|
||||
|
||||
latest_log = None
|
||||
if task:
|
||||
latest_log = db.query(DeepCompetitorAnalysisExecutionLog).filter(
|
||||
DeepCompetitorAnalysisExecutionLog.task_id == task.id
|
||||
).order_by(DeepCompetitorAnalysisExecutionLog.execution_date.desc()).first()
|
||||
|
||||
return {
|
||||
"deep_competitor_analysis": {
|
||||
"bulb": "green" if has_competitors else "red",
|
||||
"eligible": has_competitors,
|
||||
"reason": None if has_competitors else "No competitors found in Step 3 'Discovered Competitors'.",
|
||||
"task": {
|
||||
"exists": bool(task),
|
||||
"status": task.status if task else None,
|
||||
"next_execution": task.next_execution.isoformat() if task and task.next_execution else None,
|
||||
"last_run": latest_log.execution_date.isoformat() if latest_log and latest_log.execution_date else None,
|
||||
"last_status": latest_log.status if latest_log else None
|
||||
}
|
||||
},
|
||||
"competitive_sitemap_benchmarking": {
|
||||
"bulb": "green" if has_competitors else "red",
|
||||
"eligible": has_competitors,
|
||||
"reason": None if has_competitors else "No competitors found in Step 3 'Discovered Competitors'.",
|
||||
"report": {
|
||||
"available": sitemap_benchmark_available,
|
||||
"last_run": sitemap_benchmark_last_run,
|
||||
"error_count": sitemap_error_count,
|
||||
"status": sitemap_benchmark_status,
|
||||
"error": sitemap_benchmark_error
|
||||
}
|
||||
}
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
class ResearchHealthResponse(BaseModel):
|
||||
"""Response model for research service health check."""
|
||||
success: bool
|
||||
@@ -87,10 +194,57 @@ class SitemapAnalysisResponse(BaseModel):
|
||||
discovery_method: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
class SocialMediaDiscoveryRequest(BaseModel):
|
||||
"""Request model for social media discovery."""
|
||||
user_url: str = Field(..., description="User's website URL")
|
||||
|
||||
class SocialMediaDiscoveryResponse(BaseModel):
|
||||
"""Response model for social media discovery."""
|
||||
success: bool
|
||||
message: str
|
||||
social_media_accounts: Optional[Dict[str, str]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
# Initialize services
|
||||
step3_research_service = Step3ResearchService()
|
||||
sitemap_service = SitemapService()
|
||||
|
||||
@router.post("/discover-social-media", response_model=SocialMediaDiscoveryResponse)
|
||||
async def discover_social_media(
|
||||
request: SocialMediaDiscoveryRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> SocialMediaDiscoveryResponse:
|
||||
"""
|
||||
Discover social media accounts for a given website.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Starting social media discovery for user: {current_user.get('user_id', 'unknown')}")
|
||||
logger.info(f"Social media discovery request: {request.user_url}")
|
||||
|
||||
# Use ExaService directly via Step3ResearchService instance
|
||||
result = await step3_research_service.exa_service.discover_social_media_accounts(request.user_url)
|
||||
|
||||
if result["success"]:
|
||||
return SocialMediaDiscoveryResponse(
|
||||
success=True,
|
||||
message="Social media accounts discovered successfully",
|
||||
social_media_accounts=result.get("social_media_accounts", {})
|
||||
)
|
||||
else:
|
||||
return SocialMediaDiscoveryResponse(
|
||||
success=False,
|
||||
message="Social media discovery failed",
|
||||
error=result.get("error", "Unknown error")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in social media discovery: {str(e)}")
|
||||
return SocialMediaDiscoveryResponse(
|
||||
success=False,
|
||||
message="An unexpected error occurred",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
@router.post("/discover-competitors", response_model=CompetitorDiscoveryResponse)
|
||||
async def discover_competitors(
|
||||
request: CompetitorDiscoveryRequest,
|
||||
@@ -168,7 +322,10 @@ async def discover_competitors(
|
||||
)
|
||||
|
||||
@router.post("/research-data", response_model=ResearchDataResponse)
|
||||
async def get_research_data(request: ResearchDataRequest) -> ResearchDataResponse:
|
||||
async def get_research_data(
|
||||
request: ResearchDataRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> ResearchDataResponse:
|
||||
"""
|
||||
Retrieve research data for a specific onboarding session.
|
||||
|
||||
@@ -176,7 +333,10 @@ async def get_research_data(request: ResearchDataRequest) -> ResearchDataRespons
|
||||
and research summary for the given session.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Retrieving research data for session {request.session_id}")
|
||||
# Get Clerk user ID for user isolation
|
||||
clerk_user_id = str(current_user.get('id'))
|
||||
|
||||
logger.info(f"Retrieving research data for session {request.session_id} (user: {clerk_user_id})")
|
||||
|
||||
# Validate session ID
|
||||
if not request.session_id or len(request.session_id) < 10:
|
||||
@@ -186,7 +346,7 @@ async def get_research_data(request: ResearchDataRequest) -> ResearchDataRespons
|
||||
)
|
||||
|
||||
# Retrieve research data
|
||||
result = await step3_research_service.get_research_data(request.session_id)
|
||||
result = await step3_research_service.get_research_data(request.session_id, clerk_user_id)
|
||||
|
||||
if result["success"]:
|
||||
logger.info(f"Successfully retrieved research data for session {request.session_id}")
|
||||
@@ -220,6 +380,32 @@ async def get_research_data(request: ResearchDataRequest) -> ResearchDataRespons
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
@router.get("/sitemap-benchmark-report")
|
||||
async def get_sitemap_benchmark_report(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve the full sitemap benchmark report for the current user.
|
||||
"""
|
||||
user_id = str(current_user.get("id"))
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
try:
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated = integration_service.get_integrated_data_sync(user_id, db)
|
||||
|
||||
website_analysis = integrated.get("website_analysis") if isinstance(integrated, dict) else {}
|
||||
seo_audit = website_analysis.get("seo_audit") if isinstance(website_analysis, dict) else {}
|
||||
sitemap_benchmark_report = seo_audit.get("competitive_sitemap_benchmarking") if isinstance(seo_audit, dict) else None
|
||||
|
||||
if not sitemap_benchmark_report:
|
||||
raise HTTPException(status_code=404, detail="No sitemap benchmark report found")
|
||||
|
||||
return sitemap_benchmark_report
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/health", response_model=ResearchHealthResponse)
|
||||
async def health_check() -> ResearchHealthResponse:
|
||||
"""
|
||||
@@ -260,14 +446,17 @@ async def health_check() -> ResearchHealthResponse:
|
||||
)
|
||||
|
||||
@router.post("/validate-session")
|
||||
async def validate_session(session_id: str) -> Dict[str, Any]:
|
||||
async def validate_session(
|
||||
session_id: str = Body(..., embed=True),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate that a session exists and is ready for Step 3.
|
||||
|
||||
This endpoint checks if the session exists and has completed previous steps.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Validating session {session_id} for Step 3")
|
||||
logger.info(f"Validating session {session_id} for Step 3, user: {current_user.get('id')}")
|
||||
|
||||
# Basic validation
|
||||
if not session_id or len(session_id) < 10:
|
||||
@@ -290,12 +479,141 @@ async def validate_session(session_id: str) -> Dict[str, Any]:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating session: {str(e)}")
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# Deep Website Crawl Endpoints
|
||||
|
||||
class DeepCrawlRequest(BaseModel):
|
||||
user_url: str
|
||||
schedule: bool = False
|
||||
|
||||
@router.post("/deep-crawl/start")
|
||||
async def start_deep_crawl(
|
||||
request: DeepCrawlRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Start a deep website crawl task.
|
||||
If schedule is True, it sets up the recurring task.
|
||||
If schedule is False, it runs immediately (fire and forget/poll).
|
||||
"""
|
||||
user_id = str(current_user.get("id"))
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
try:
|
||||
# Check/Create Task
|
||||
task = db.query(DeepWebsiteCrawlTask).filter(
|
||||
DeepWebsiteCrawlTask.user_id == user_id,
|
||||
DeepWebsiteCrawlTask.website_url == request.user_url
|
||||
).first()
|
||||
|
||||
if not task:
|
||||
task = DeepWebsiteCrawlTask(
|
||||
user_id=user_id,
|
||||
website_url=request.user_url,
|
||||
status="active" if request.schedule else "running",
|
||||
next_execution=datetime.utcnow() if request.schedule else None
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
else:
|
||||
task.website_url = request.user_url # Update URL if changed?
|
||||
if request.schedule:
|
||||
task.status = "active"
|
||||
# If scheduling, don't run immediately unless requested?
|
||||
# User said "fire ... OR let it be scheduled".
|
||||
# If this endpoint is called, we assume intent to start OR schedule.
|
||||
# If schedule=True, we might just set it active.
|
||||
# If schedule=False, we run it now.
|
||||
# But typically user might want "Run now AND schedule".
|
||||
# Let's assume this endpoint is "Start Now". Scheduling is separate?
|
||||
# "option to fire and check ... or let it be scheduled"
|
||||
# If "fire", run now.
|
||||
pass
|
||||
else:
|
||||
task.status = "running"
|
||||
db.commit()
|
||||
|
||||
if not request.schedule:
|
||||
# Run immediately in background
|
||||
service = DeepCrawlService()
|
||||
background_tasks.add_task(
|
||||
service.execute_deep_crawl,
|
||||
user_id=user_id,
|
||||
website_url=request.user_url,
|
||||
task_id=task.id
|
||||
)
|
||||
message = "Deep crawl started immediately."
|
||||
else:
|
||||
# Scheduled
|
||||
task.status = "active"
|
||||
task.next_execution = datetime.utcnow() # Scheduler will pick it up
|
||||
db.commit()
|
||||
message = "Deep crawl scheduled."
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Session validation failed",
|
||||
"error": str(e)
|
||||
"success": True,
|
||||
"message": message,
|
||||
"task_id": task.id,
|
||||
"status": task.status
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting deep crawl: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/deep-crawl/status")
|
||||
async def get_deep_crawl_status(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get status of the deep website crawl task.
|
||||
"""
|
||||
user_id = str(current_user.get("id"))
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
try:
|
||||
task = db.query(DeepWebsiteCrawlTask).filter(
|
||||
DeepWebsiteCrawlTask.user_id == user_id
|
||||
).order_by(DeepWebsiteCrawlTask.id.desc()).first()
|
||||
|
||||
if not task:
|
||||
return {
|
||||
"exists": False,
|
||||
"status": None
|
||||
}
|
||||
|
||||
latest_log = db.query(DeepWebsiteCrawlExecutionLog).filter(
|
||||
DeepWebsiteCrawlExecutionLog.task_id == task.id
|
||||
).order_by(DeepWebsiteCrawlExecutionLog.execution_date.desc()).first()
|
||||
|
||||
return {
|
||||
"exists": True,
|
||||
"task_id": task.id,
|
||||
"status": task.status,
|
||||
"last_executed": task.last_executed,
|
||||
"next_execution": task.next_execution,
|
||||
"latest_log": {
|
||||
"status": latest_log.status if latest_log else None,
|
||||
"execution_date": latest_log.execution_date if latest_log else None,
|
||||
"result_summary": latest_log.result_data if latest_log else None,
|
||||
"error": latest_log.error_message if latest_log else None
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting deep crawl status: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/cost-estimate")
|
||||
async def get_cost_estimate(
|
||||
@@ -421,7 +739,8 @@ async def analyze_sitemap_for_onboarding(
|
||||
competitors=request.competitors,
|
||||
industry_context=request.industry_context,
|
||||
analyze_content_trends=request.analyze_content_trends,
|
||||
analyze_publishing_patterns=request.analyze_publishing_patterns
|
||||
analyze_publishing_patterns=request.analyze_publishing_patterns,
|
||||
user_id=str(current_user.get('id'))
|
||||
)
|
||||
|
||||
# Check if analysis was successful
|
||||
|
||||
196
backend/api/onboarding_utils/step4_asset_routes.py
Normal file
196
backend/api/onboarding_utils/step4_asset_routes.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Step 4 Brand Asset Routes
|
||||
Handles brand avatar generation, enhancement, and variation.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends, File, UploadFile, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
from .step4_persona_routes import _extract_user_id
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
from utils.file_storage import save_file_safely, generate_unique_filename
|
||||
from services.database import get_db, WORKSPACE_DIR
|
||||
from utils.asset_tracker import save_asset_to_library
|
||||
|
||||
from services.llm_providers.main_image_generation import (
|
||||
generate_image_with_provider,
|
||||
enhance_image_prompt,
|
||||
generate_image_variation
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- Models ---
|
||||
class AvatarPromptRequest(BaseModel):
|
||||
user_id: Optional[str] = None
|
||||
prompt: str
|
||||
aspect_ratio: str = "1:1"
|
||||
style_preset: Optional[str] = None
|
||||
negative_prompt: Optional[str] = None
|
||||
num_inference_steps: int = 30
|
||||
guidance_scale: float = 7.5
|
||||
|
||||
class AvatarEnhanceRequest(BaseModel):
|
||||
user_id: Optional[str] = None
|
||||
prompt: str
|
||||
|
||||
class VoiceCloneRequest(BaseModel):
|
||||
user_id: Optional[str] = None
|
||||
voice_name: str
|
||||
description: Optional[str] = None
|
||||
engine: str = "qwen3" # qwen3 or minimax
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@router.post("/generate-avatar")
|
||||
async def generate_avatar(
|
||||
request: AvatarPromptRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Generate a brand avatar using available image providers."""
|
||||
try:
|
||||
user_id = _extract_user_id(request.user_id)
|
||||
|
||||
logger.info(f"Generating avatar for user {user_id} with prompt: {request.prompt}")
|
||||
|
||||
# 1. Generate Image
|
||||
result = await generate_image_with_provider(
|
||||
prompt=request.prompt,
|
||||
aspect_ratio=request.aspect_ratio,
|
||||
negative_prompt=request.negative_prompt,
|
||||
num_inference_steps=request.num_inference_steps,
|
||||
guidance_scale=request.guidance_scale,
|
||||
style_preset=request.style_preset,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error", "Generation failed"))
|
||||
|
||||
# 2. Save to local storage and Asset Library
|
||||
# The result typically contains image_base64 or image_url
|
||||
# For simplicity, we assume image_base64 is returned or we download the URL
|
||||
|
||||
image_data = result.get("image_base64")
|
||||
if not image_data and result.get("image_url"):
|
||||
# TODO: Download image from URL if needed, or just store URL
|
||||
pass
|
||||
|
||||
if image_data:
|
||||
# Decode if needed (usually it's already base64 string)
|
||||
# Save file
|
||||
filename = generate_unique_filename("avatar", "png")
|
||||
file_path = save_file_safely(
|
||||
base64.b64decode(image_data) if isinstance(image_data, str) else image_data,
|
||||
user_id,
|
||||
"avatars",
|
||||
filename
|
||||
)
|
||||
|
||||
# Save to Asset Library
|
||||
asset_id = save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
file_path=file_path,
|
||||
asset_type="image",
|
||||
category="brand_avatar",
|
||||
meta_data={
|
||||
"prompt": request.prompt,
|
||||
"provider": result.get("provider", "unknown"),
|
||||
"style": request.style_preset
|
||||
}
|
||||
)
|
||||
|
||||
# Construct public URL (this depends on your static file serving setup)
|
||||
# Assuming /api/assets/{user_id}/avatars/{filename}
|
||||
image_url = f"/api/assets/{user_id}/avatars/{filename}"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"image_url": image_url,
|
||||
"image_base64": image_data, # Optional: return base64 for immediate display
|
||||
"asset_id": asset_id
|
||||
}
|
||||
|
||||
return {"success": False, "error": "No image data returned"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Avatar generation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/enhance-prompt")
|
||||
async def enhance_prompt_route(
|
||||
request: AvatarEnhanceRequest
|
||||
):
|
||||
"""Enhance a simple prompt into a detailed midjourney-style prompt."""
|
||||
try:
|
||||
user_id = _extract_user_id(request.user_id)
|
||||
logger.info(f"Enhancing prompt for user {user_id}: {request.prompt}")
|
||||
|
||||
enhanced_prompt = await enhance_image_prompt(request.prompt)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"original_prompt": request.prompt,
|
||||
"optimized_prompt": enhanced_prompt
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Prompt enhancement failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/create-voice-clone")
|
||||
async def create_voice_clone(
|
||||
voice_name: str = Form(...),
|
||||
description: str = Form(None),
|
||||
engine: str = Form("qwen3"),
|
||||
file: UploadFile = File(...),
|
||||
user_id: Optional[str] = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a voice clone from an audio file."""
|
||||
try:
|
||||
user_id = _extract_user_id(user_id)
|
||||
logger.info(f"Creating voice clone '{voice_name}' for user {user_id}")
|
||||
|
||||
# 1. Save uploaded audio file
|
||||
file_content = await file.read()
|
||||
filename = generate_unique_filename("voice_sample", Path(file.filename).suffix.lstrip("."))
|
||||
file_path = save_file_safely(file_content, user_id, "voice_samples", filename)
|
||||
|
||||
# 2. Call Voice Cloning API (Placeholder for actual implementation)
|
||||
# TODO: Integrate with Minimax or CosyVoice API
|
||||
# For now, we simulate success
|
||||
|
||||
# 3. Save to Asset Library
|
||||
asset_id = save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
file_path=file_path,
|
||||
asset_type="audio",
|
||||
category="voice_clone",
|
||||
meta_data={
|
||||
"voice_name": voice_name,
|
||||
"engine": engine,
|
||||
"description": description,
|
||||
"original_filename": file.filename
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"custom_voice_id": f"vc_{asset_id}", # Mock ID
|
||||
"preview_audio_url": f"/api/assets/{user_id}/voice_samples/{filename}",
|
||||
"asset_id": asset_id,
|
||||
"message": "Voice clone created successfully (simulated)"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Voice cloning failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -202,11 +202,24 @@ async def get_latest_persona(current_user: Dict[str, Any] = Depends(get_current_
|
||||
raise HTTPException(status_code=404, detail="Cached persona expired")
|
||||
|
||||
return {"success": True, "persona": cached}
|
||||
except HTTPException:
|
||||
raise
|
||||
except HTTPException as he:
|
||||
# Return 200 even for HTTP exceptions (like 404) to prevent frontend connection errors
|
||||
# if the endpoint is called during an auto-initialization phase.
|
||||
logger.warning(f"Persona retrieval notice (returning success=False): {he.detail}")
|
||||
return {
|
||||
"success": False,
|
||||
"persona": None,
|
||||
"message": he.detail,
|
||||
"status_code": he.status_code
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting latest persona: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error(f"Error getting latest persona: {e}", exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"persona": None,
|
||||
"message": f"Internal error retrieving persona: {str(e)}",
|
||||
"status_code": 500
|
||||
}
|
||||
|
||||
@router.post("/step4/persona-save", response_model=Dict[str, Any])
|
||||
async def save_persona_update(
|
||||
@@ -228,8 +241,12 @@ async def save_persona_update(
|
||||
logger.info(f"Saved latest persona to cache for user {user_id}")
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving latest persona: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error(f"Error saving latest persona: {e}", exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to save persona: {str(e)}",
|
||||
"status_code": 500
|
||||
}
|
||||
|
||||
@router.get("/step4/persona-task/{task_id}", response_model=PersonaTaskStatus)
|
||||
async def get_persona_task_status(task_id: str):
|
||||
|
||||
@@ -4,24 +4,315 @@ Handles onboarding step operations and progress tracking.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from services.onboarding.progress_service import get_onboarding_progress_service
|
||||
from services.onboarding.database_service import OnboardingDatabaseService
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
from services.database import get_db
|
||||
from models.onboarding import OnboardingSession, APIKey, WebsiteAnalysis, ResearchPreferences, PersonaData, CompetitorAnalysis
|
||||
|
||||
class StepManagementService:
|
||||
"""Service for handling onboarding step management."""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
self.integration_service = OnboardingDataIntegrationService()
|
||||
|
||||
def _get_or_create_session(self, user_id: str, db: Session) -> OnboardingSession:
|
||||
"""Get or create onboarding session."""
|
||||
session = db.query(OnboardingSession).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
session = OnboardingSession(
|
||||
user_id=user_id,
|
||||
current_step=1,
|
||||
progress=0.0,
|
||||
started_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
|
||||
return session
|
||||
|
||||
def _save_api_key(self, user_id: str, provider: str, api_key: str, db: Session) -> bool:
|
||||
"""Save API key directly to database."""
|
||||
try:
|
||||
session = self._get_or_create_session(user_id, db)
|
||||
|
||||
existing_key = db.query(APIKey).filter(
|
||||
APIKey.session_id == session.id,
|
||||
APIKey.provider == provider
|
||||
).first()
|
||||
|
||||
if existing_key:
|
||||
existing_key.key = api_key
|
||||
existing_key.updated_at = datetime.utcnow()
|
||||
else:
|
||||
new_key = APIKey(
|
||||
session_id=session.id,
|
||||
provider=provider,
|
||||
key=api_key
|
||||
)
|
||||
db.add(new_key)
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving API key for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
raise e
|
||||
|
||||
def _save_website_analysis(self, user_id: str, analysis_data: Dict[str, Any], db: Session) -> bool:
|
||||
"""Save website analysis directly to database."""
|
||||
try:
|
||||
session = self._get_or_create_session(user_id, db)
|
||||
|
||||
# Normalize payload
|
||||
incoming = analysis_data or {}
|
||||
nested = incoming.get('analysis') if isinstance(incoming.get('analysis'), dict) else None
|
||||
|
||||
# Extract extra fields
|
||||
brand_analysis = (nested or incoming).get('brand_analysis')
|
||||
content_strategy_insights = (nested or incoming).get('content_strategy_insights')
|
||||
meta_info = (nested or incoming).get('meta_info')
|
||||
|
||||
# Fix: Check both nested and incoming for social_media_presence
|
||||
social_media_presence = (nested or {}).get('social_media_presence') or incoming.get('social_media_presence')
|
||||
|
||||
seo_audit = (nested or incoming).get('seo_audit')
|
||||
style_patterns = (nested or incoming).get('style_patterns')
|
||||
style_guidelines = (nested or incoming).get('guidelines')
|
||||
sitemap_analysis = (nested or incoming).get('sitemap_analysis')
|
||||
|
||||
# Prepare crawl_result
|
||||
crawl_result = incoming.get('crawl_result') or {}
|
||||
if not isinstance(crawl_result, dict):
|
||||
crawl_result = {"raw": crawl_result}
|
||||
|
||||
# Meta info still goes to crawl_result as we didn't add a column for it
|
||||
if meta_info:
|
||||
crawl_result['meta_info'] = meta_info
|
||||
|
||||
# Store sitemap_analysis in crawl_result as we don't have a dedicated column yet
|
||||
if sitemap_analysis:
|
||||
crawl_result['sitemap_analysis'] = sitemap_analysis
|
||||
|
||||
normalized = {
|
||||
'website_url': incoming.get('website') or incoming.get('website_url') or '',
|
||||
'writing_style': (nested or incoming).get('writing_style'),
|
||||
'content_characteristics': (nested or incoming).get('content_characteristics'),
|
||||
'target_audience': (nested or incoming).get('target_audience'),
|
||||
'content_type': (nested or incoming).get('content_type'),
|
||||
'recommended_settings': (nested or incoming).get('recommended_settings'),
|
||||
'brand_analysis': brand_analysis,
|
||||
'content_strategy_insights': content_strategy_insights,
|
||||
'social_media_presence': social_media_presence,
|
||||
'crawl_result': crawl_result,
|
||||
'seo_audit': seo_audit,
|
||||
'style_patterns': style_patterns,
|
||||
'style_guidelines': style_guidelines
|
||||
}
|
||||
|
||||
# Filter only valid columns to prevent TypeError
|
||||
valid_columns = [c.name for c in WebsiteAnalysis.__table__.columns if c.name not in ['id', 'session_id', 'created_at', 'updated_at']]
|
||||
filtered_data = {k: v for k, v in normalized.items() if k in valid_columns and v is not None}
|
||||
|
||||
existing_analysis = db.query(WebsiteAnalysis).filter(
|
||||
WebsiteAnalysis.session_id == session.id
|
||||
).first()
|
||||
|
||||
if existing_analysis:
|
||||
for key, value in filtered_data.items():
|
||||
setattr(existing_analysis, key, value)
|
||||
existing_analysis.updated_at = datetime.utcnow()
|
||||
else:
|
||||
new_analysis = WebsiteAnalysis(
|
||||
session_id=session.id,
|
||||
**filtered_data
|
||||
)
|
||||
db.add(new_analysis)
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving website analysis for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
raise e
|
||||
|
||||
def _save_research_preferences(self, user_id: str, research_data: Dict[str, Any], db: Session) -> bool:
|
||||
"""Save research preferences directly to database."""
|
||||
try:
|
||||
session = self._get_or_create_session(user_id, db)
|
||||
|
||||
# Add defaults for required fields if missing to prevent 500 errors
|
||||
# The frontend Step 3 (Competitor Analysis) might not send these
|
||||
if 'research_depth' not in research_data:
|
||||
research_data['research_depth'] = 'Comprehensive'
|
||||
if 'content_types' not in research_data:
|
||||
research_data['content_types'] = ["Blog Posts", "Social Media", "Newsletters"]
|
||||
if 'auto_research' not in research_data:
|
||||
research_data['auto_research'] = True
|
||||
if 'factual_content' not in research_data:
|
||||
research_data['factual_content'] = True
|
||||
|
||||
existing_prefs = db.query(ResearchPreferences).filter(
|
||||
ResearchPreferences.session_id == session.id
|
||||
).first()
|
||||
|
||||
if existing_prefs:
|
||||
# Fix for SQLite DateTime issue: Ensure created_at is a datetime object
|
||||
if hasattr(existing_prefs, 'created_at') and isinstance(existing_prefs.created_at, str):
|
||||
try:
|
||||
existing_prefs.created_at = datetime.fromisoformat(existing_prefs.created_at)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
for key, value in research_data.items():
|
||||
# Skip metadata fields and id
|
||||
if key in ['id', 'session_id', 'created_at', 'updated_at']:
|
||||
continue
|
||||
|
||||
if hasattr(existing_prefs, key) and value is not None:
|
||||
setattr(existing_prefs, key, value)
|
||||
existing_prefs.updated_at = datetime.utcnow()
|
||||
else:
|
||||
# Filter valid columns only to avoid errors
|
||||
valid_columns = [c.name for c in ResearchPreferences.__table__.columns if c.name not in ['id', 'session_id', 'created_at', 'updated_at']]
|
||||
filtered_data = {k: v for k, v in research_data.items() if k in valid_columns}
|
||||
|
||||
new_prefs = ResearchPreferences(
|
||||
session_id=session.id,
|
||||
**filtered_data
|
||||
)
|
||||
db.add(new_prefs)
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving research preferences for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
raise e
|
||||
|
||||
def _save_competitor_analysis(self, user_id: str, competitors: List[Dict[str, Any]], industry_context: Optional[str], db: Session) -> bool:
|
||||
"""Save competitor analysis results to database."""
|
||||
try:
|
||||
session = self._get_or_create_session(user_id, db)
|
||||
|
||||
logger.info(f"🔍 COMPETITOR SAVE: Starting to save {len(competitors)} competitors for session {session.id}")
|
||||
|
||||
saved_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for idx, competitor in enumerate(competitors):
|
||||
try:
|
||||
if not competitor or not isinstance(competitor, dict):
|
||||
logger.warning(f" ⚠️ Skipping invalid competitor entry at index {idx}: {competitor}")
|
||||
continue
|
||||
|
||||
# Use full URL (Text column supports it) and clean it
|
||||
raw_url = competitor.get("url", "")
|
||||
competitor_url = raw_url.strip().strip('`').strip() if raw_url else ""
|
||||
|
||||
# Prepare analysis data
|
||||
analysis_data = {
|
||||
"title": competitor.get("title", ""),
|
||||
"summary": competitor.get("summary", ""),
|
||||
"relevance_score": competitor.get("relevance_score", 0.5),
|
||||
"highlights": competitor.get("highlights", []),
|
||||
"subpages": competitor.get("subpages", []),
|
||||
"favicon": competitor.get("favicon"),
|
||||
"image": competitor.get("image"),
|
||||
"published_date": competitor.get("published_date"),
|
||||
"author": competitor.get("author"),
|
||||
"competitive_analysis": competitor.get("competitive_analysis") or competitor.get("competitive_insights", {}),
|
||||
"content_insights": competitor.get("content_insights", {}),
|
||||
"industry_context": industry_context,
|
||||
"completed_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# Check if competitor already exists for this session
|
||||
existing_competitor = db.query(CompetitorAnalysis).filter(
|
||||
CompetitorAnalysis.session_id == session.id,
|
||||
CompetitorAnalysis.competitor_url == competitor.get("url", "")
|
||||
).first()
|
||||
|
||||
has_details = bool(analysis_data.get("summary") or analysis_data.get("highlights"))
|
||||
detail_msg = "with rich details" if has_details else "basic info only"
|
||||
|
||||
if existing_competitor:
|
||||
existing_competitor.analysis_data = analysis_data
|
||||
existing_competitor.updated_at = datetime.utcnow()
|
||||
logger.info(f" Updated existing competitor {idx + 1} ({detail_msg})")
|
||||
else:
|
||||
competitor_record = CompetitorAnalysis(
|
||||
session_id=session.id,
|
||||
competitor_url=competitor_url,
|
||||
competitor_domain=competitor.get("domain", ""),
|
||||
analysis_data=analysis_data,
|
||||
status="completed"
|
||||
)
|
||||
db.add(competitor_record)
|
||||
logger.info(f" Added new competitor {idx + 1} ({detail_msg})")
|
||||
|
||||
saved_count += 1
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f" ❌ Failed to save competitor {idx + 1}: {str(e)}")
|
||||
|
||||
db.commit()
|
||||
logger.info(f"✅ Saved {saved_count} competitors ({failed_count} failed)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving competitor analysis for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
raise e
|
||||
|
||||
|
||||
def _save_persona_data(self, user_id: str, persona_data: Dict[str, Any], db: Session) -> bool:
|
||||
"""Save persona data directly to database."""
|
||||
try:
|
||||
session = self._get_or_create_session(user_id, db)
|
||||
|
||||
existing = db.query(PersonaData).filter(
|
||||
PersonaData.session_id == session.id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.core_persona = persona_data.get('corePersona')
|
||||
existing.platform_personas = persona_data.get('platformPersonas')
|
||||
existing.quality_metrics = persona_data.get('qualityMetrics')
|
||||
existing.selected_platforms = persona_data.get('selectedPlatforms', [])
|
||||
existing.updated_at = datetime.utcnow()
|
||||
else:
|
||||
persona = PersonaData(
|
||||
session_id=session.id,
|
||||
core_persona=persona_data.get('corePersona'),
|
||||
platform_personas=persona_data.get('platformPersonas'),
|
||||
quality_metrics=persona_data.get('qualityMetrics'),
|
||||
selected_platforms=persona_data.get('selectedPlatforms', [])
|
||||
)
|
||||
db.add(persona)
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving persona data for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
raise e
|
||||
|
||||
async def get_onboarding_status(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get the current onboarding status (per user)."""
|
||||
try:
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
user_id = str(current_user.get('id'))
|
||||
status = get_onboarding_progress_service().get_onboarding_status(user_id)
|
||||
status = OnboardingProgressService().get_onboarding_status(user_id)
|
||||
return {
|
||||
"is_completed": status["is_completed"],
|
||||
"current_step": status["current_step"],
|
||||
@@ -38,8 +329,9 @@ class StepManagementService:
|
||||
async def get_onboarding_progress_full(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get the full onboarding progress data."""
|
||||
try:
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
user_id = str(current_user.get('id'))
|
||||
progress_service = get_onboarding_progress_service()
|
||||
progress_service = OnboardingProgressService()
|
||||
status = progress_service.get_onboarding_status(user_id)
|
||||
data = progress_service.get_completion_data(user_id)
|
||||
|
||||
@@ -125,11 +417,13 @@ class StepManagementService:
|
||||
"""Get data for a specific step."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db = next(get_db())
|
||||
db_service = OnboardingDatabaseService()
|
||||
db = next(get_db(current_user))
|
||||
|
||||
# Use SSOT for reading step data
|
||||
integrated_data = self.integration_service.get_integrated_data_sync(user_id, db)
|
||||
|
||||
if step_number == 2:
|
||||
website = db_service.get_website_analysis(user_id, db) or {}
|
||||
website = integrated_data.get('website_analysis', {})
|
||||
return {
|
||||
"step_number": 2,
|
||||
"title": "Website",
|
||||
@@ -140,18 +434,27 @@ class StepManagementService:
|
||||
"validation_errors": []
|
||||
}
|
||||
if step_number == 3:
|
||||
research = db_service.get_research_preferences(user_id, db) or {}
|
||||
research = integrated_data.get('research_preferences', {})
|
||||
competitors = integrated_data.get('competitor_analysis', [])
|
||||
website = integrated_data.get('website_analysis', {})
|
||||
social_media = website.get('social_media_presence') or website.get('social_media_accounts', {})
|
||||
|
||||
# Merge competitors into the data
|
||||
step_data = research.copy() if research else {}
|
||||
step_data['competitors'] = competitors
|
||||
step_data['social_media_accounts'] = social_media
|
||||
|
||||
return {
|
||||
"step_number": 3,
|
||||
"title": "Research",
|
||||
"description": "Discover competitors",
|
||||
"status": 'completed' if (research.get('research_depth') or research.get('content_types')) else 'pending',
|
||||
"status": 'completed' if (research.get('research_depth') or research.get('content_types') or competitors) else 'pending',
|
||||
"completed_at": None,
|
||||
"data": research,
|
||||
"data": step_data,
|
||||
"validation_errors": []
|
||||
}
|
||||
if step_number == 4:
|
||||
persona = db_service.get_persona_data(user_id, db) or {}
|
||||
persona = integrated_data.get('persona_data', {})
|
||||
return {
|
||||
"step_number": 4,
|
||||
"title": "Personalization",
|
||||
@@ -162,7 +465,8 @@ class StepManagementService:
|
||||
"validation_errors": []
|
||||
}
|
||||
|
||||
status = get_onboarding_progress_service().get_onboarding_status(user_id)
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
status = OnboardingProgressService().get_onboarding_status(user_id)
|
||||
mapping = {
|
||||
1: ('API Keys', 'Connect your AI services', status['current_step'] >= 1),
|
||||
5: ('Integrations', 'Connect additional services', status['current_step'] >= 5),
|
||||
@@ -201,8 +505,7 @@ class StepManagementService:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
db = next(get_db())
|
||||
db_service = OnboardingDatabaseService()
|
||||
db = next(get_db(current_user))
|
||||
|
||||
save_errors = [] # Track save failures
|
||||
|
||||
@@ -218,12 +521,9 @@ class StepManagementService:
|
||||
for provider, key in api_keys.items():
|
||||
if key:
|
||||
try:
|
||||
saved = db_service.save_api_key(user_id, provider, key, db)
|
||||
saved = self._save_api_key(user_id, provider, key, db)
|
||||
if saved:
|
||||
logger.info(f"✅ Saved API key for provider {provider}")
|
||||
else:
|
||||
# This should not happen anymore since save_api_key now raises exceptions
|
||||
raise Exception(f"API key save returned False for provider {provider}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BLOCKING ERROR: Failed to save API key for provider {provider}: {str(e)}")
|
||||
raise HTTPException(
|
||||
@@ -236,18 +536,36 @@ class StepManagementService:
|
||||
website_data = request_data.get('data') or request_data
|
||||
logger.info(f"🔍 Step 2: Raw request_data keys: {list(request_data.keys()) if request_data else 'None'}")
|
||||
logger.info(f"🔍 Step 2: Extracted website_data keys: {list(website_data.keys()) if website_data else 'None'}")
|
||||
logger.info(f"🔍 Step 2: website_data.website: {website_data.get('website') if website_data else 'None'}")
|
||||
logger.info(f"🔍 Step 2: website_data.analysis: {bool(website_data.get('analysis')) if website_data else 'None'}")
|
||||
if website_data.get('analysis'):
|
||||
logger.info(f"🔍 Step 2: analysis keys: {list(website_data['analysis'].keys()) if isinstance(website_data.get('analysis'), dict) else 'Not dict'}")
|
||||
if website_data:
|
||||
try:
|
||||
saved = db_service.save_website_analysis(user_id, website_data, db)
|
||||
saved = self._save_website_analysis(user_id, website_data, db)
|
||||
if saved:
|
||||
logger.info(f"✅ Saved website analysis for user {user_id}")
|
||||
else:
|
||||
# This should not happen anymore since save_website_analysis now raises exceptions
|
||||
raise Exception("Website analysis save returned False")
|
||||
|
||||
# Trigger Advertools persona augmentation (Phase 1)
|
||||
try:
|
||||
from services.scheduler import get_scheduler
|
||||
|
||||
website_url = website_data.get('website') or website_data.get('website_url')
|
||||
if website_url:
|
||||
scheduler = get_scheduler()
|
||||
# Schedule content audit for persona augmentation
|
||||
scheduler.schedule_one_time_task(
|
||||
func=scheduler.execute_task_by_type,
|
||||
run_date=datetime.utcnow() + timedelta(seconds=10), # Start in 10s
|
||||
job_id=f"advertools_persona_augmentation_{user_id}",
|
||||
kwargs={
|
||||
"task_type": "advertools_intelligence",
|
||||
"user_id": user_id,
|
||||
"payload": {
|
||||
"type": "content_audit",
|
||||
"website_url": website_url
|
||||
}
|
||||
}
|
||||
)
|
||||
logger.info(f"🚀 Triggered Advertools persona augmentation for {website_url}")
|
||||
except Exception as sched_err:
|
||||
logger.error(f"Failed to trigger Advertools augmentation: {sched_err}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BLOCKING ERROR: Failed to save website analysis: {str(e)}")
|
||||
raise HTTPException(
|
||||
@@ -261,15 +579,38 @@ class StepManagementService:
|
||||
logger.info(f"🔍 Step 3: Raw request_data keys: {list(request_data.keys()) if request_data else 'None'}")
|
||||
logger.info(f"🔍 Step 3: Extracted research_data keys: {list(research_data.keys()) if research_data else 'None'}")
|
||||
if research_data:
|
||||
# Note: Competitor data is saved separately via discover-competitors endpoint
|
||||
# This saves research preferences (content_types, target_audience, etc.)
|
||||
try:
|
||||
saved = db_service.save_research_preferences(user_id, research_data, db)
|
||||
saved = self._save_research_preferences(user_id, research_data, db)
|
||||
if saved:
|
||||
logger.info(f"✅ Saved research preferences for user {user_id}")
|
||||
else:
|
||||
# This should not happen anymore since save_research_preferences now raises exceptions
|
||||
raise Exception("Research preferences save returned False")
|
||||
|
||||
# Also save competitors if present
|
||||
competitors = research_data.get('competitors')
|
||||
if competitors:
|
||||
industry_context = research_data.get('industryContext') or research_data.get('industry_context')
|
||||
logger.info(f"🔍 Step 3: Found {len(competitors)} competitors to save")
|
||||
self._save_competitor_analysis(user_id, competitors, industry_context, db)
|
||||
|
||||
# Save social media presence if available (Update WebsiteAnalysis)
|
||||
social_media = research_data.get('social_media_accounts')
|
||||
if social_media:
|
||||
logger.info(f"🔍 Step 3: Found social media accounts to save")
|
||||
try:
|
||||
session = self._get_or_create_session(user_id, db)
|
||||
existing_analysis = db.query(WebsiteAnalysis).filter(
|
||||
WebsiteAnalysis.session_id == session.id
|
||||
).first()
|
||||
if existing_analysis:
|
||||
existing_analysis.social_media_presence = social_media
|
||||
existing_analysis.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
logger.info(f"✅ Updated social media presence for user {user_id}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Could not save social media: WebsiteAnalysis not found for user {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to save social media presence: {str(e)}")
|
||||
# Don't block completion for this, as it's secondary data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BLOCKING ERROR: Failed to save research preferences: {str(e)}")
|
||||
raise HTTPException(
|
||||
@@ -284,12 +625,9 @@ class StepManagementService:
|
||||
logger.info(f"🔍 Step 4: Extracted persona_data keys: {list(persona_data.keys()) if persona_data else 'None'}")
|
||||
if persona_data:
|
||||
try:
|
||||
saved = db_service.save_persona_data(user_id, persona_data, db)
|
||||
saved = self._save_persona_data(user_id, persona_data, db)
|
||||
if saved:
|
||||
logger.info(f"✅ Saved persona data for user {user_id}")
|
||||
else:
|
||||
# This should not happen anymore since save_persona_data now raises exceptions
|
||||
raise Exception("Persona data save returned False")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BLOCKING ERROR: Failed to save persona data: {str(e)}")
|
||||
raise HTTPException(
|
||||
@@ -298,10 +636,12 @@ class StepManagementService:
|
||||
) from e
|
||||
|
||||
# Persist current step and progress in DB
|
||||
db_service.update_step(user_id, step_number, db)
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
progress_service = OnboardingProgressService()
|
||||
progress_service.update_step(user_id, step_number)
|
||||
try:
|
||||
progress_pct = min(100.0, round((step_number / 6) * 100))
|
||||
db_service.update_progress(user_id, float(progress_pct), db)
|
||||
progress_service.update_progress(user_id, float(progress_pct))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update progress: {e}")
|
||||
|
||||
@@ -309,6 +649,10 @@ class StepManagementService:
|
||||
if save_errors:
|
||||
logger.warning(f"⚠️ Step {step_number} completed but some data save operations failed: {save_errors}")
|
||||
|
||||
# Refresh SSOT (Canonical Profile) - non-blocking try/except inside method
|
||||
if not save_errors:
|
||||
await self.integration_service.refresh_integrated_data(user_id, db)
|
||||
|
||||
logger.info(f"[complete_step] Step {step_number} persisted to DB for user {user_id}")
|
||||
return {
|
||||
"message": "Step completed successfully",
|
||||
@@ -327,6 +671,7 @@ class StepManagementService:
|
||||
async def skip_step(self, step_number: int, current_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Skip a step (for optional steps)."""
|
||||
try:
|
||||
from services.onboarding.api_key_manager import get_onboarding_progress_for_user
|
||||
user_id = str(current_user.get('id'))
|
||||
progress = get_onboarding_progress_for_user(user_id)
|
||||
step = progress.get_step_data(step_number)
|
||||
|
||||
@@ -69,7 +69,7 @@ def get_persona_service() -> PersonaAnalysisService:
|
||||
"""Get the persona analysis service instance."""
|
||||
return PersonaAnalysisService()
|
||||
|
||||
async def generate_persona(user_id: int, request: PersonaGenerationRequest):
|
||||
async def generate_persona(user_id: str, request: PersonaGenerationRequest):
|
||||
"""Generate a new writing persona from onboarding data."""
|
||||
try:
|
||||
logger.info(f"Generating persona for user {user_id}")
|
||||
@@ -302,10 +302,10 @@ async def generate_platform_persona(user_id: str, platform: str, db_session):
|
||||
|
||||
# Import services
|
||||
from services.persona_data_service import PersonaDataService
|
||||
from services.onboarding.database_service import OnboardingDatabaseService
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
|
||||
persona_data_service = PersonaDataService(db_session=db_session)
|
||||
onboarding_service = OnboardingDatabaseService(db=db_session)
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
|
||||
# Get core persona data
|
||||
persona_data = persona_data_service.get_user_persona_data(user_id)
|
||||
@@ -316,14 +316,16 @@ async def generate_platform_persona(user_id: str, platform: str, db_session):
|
||||
if not core_persona:
|
||||
raise HTTPException(status_code=404, detail="Core persona data is empty")
|
||||
|
||||
# Get onboarding data for context
|
||||
onboarding_session = onboarding_service.get_session_by_user(user_id)
|
||||
# Get onboarding data for context using SSOT
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db_session)
|
||||
onboarding_session = integrated_data.get('onboarding_session')
|
||||
|
||||
if not onboarding_session:
|
||||
raise HTTPException(status_code=404, detail="Onboarding session not found")
|
||||
|
||||
# Get website analysis for context
|
||||
website_analysis = onboarding_service.get_website_analysis(user_id)
|
||||
research_prefs = onboarding_service.get_research_preferences(user_id)
|
||||
website_analysis = integrated_data.get('website_analysis', {})
|
||||
research_prefs = integrated_data.get('research_preferences', {})
|
||||
|
||||
onboarding_data = {
|
||||
"website_url": website_analysis.get('website_url', '') if website_analysis else '',
|
||||
@@ -456,7 +458,7 @@ async def validate_persona_generation_readiness(user_id: int):
|
||||
logger.error(f"Error validating persona generation readiness: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to validate readiness: {str(e)}")
|
||||
|
||||
async def generate_persona_preview(user_id: int):
|
||||
async def generate_persona_preview(user_id: str):
|
||||
"""Generate a preview of what the persona would look like without saving."""
|
||||
try:
|
||||
persona_service = get_persona_service()
|
||||
@@ -758,4 +760,4 @@ async def optimize_facebook_persona(
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to optimize Facebook persona: {str(e)}"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -44,9 +44,10 @@ router = APIRouter(prefix="/api/personas", tags=["personas"])
|
||||
@router.post("/generate")
|
||||
async def generate_persona_endpoint(
|
||||
request: PersonaGenerationRequest,
|
||||
user_id: int = Query(1, description="User ID")
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Generate a new writing persona from onboarding data."""
|
||||
user_id = str(current_user.get('id'))
|
||||
return await generate_persona(user_id, request)
|
||||
|
||||
@router.get("/user")
|
||||
@@ -256,4 +257,4 @@ async def check_facebook_persona_endpoint(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check if Facebook persona exists for user."""
|
||||
return await check_facebook_persona(user_id, db)
|
||||
return await check_facebook_persona(user_id, db)
|
||||
|
||||
@@ -12,12 +12,15 @@ from services.story_writer.audio_generation_service import StoryAudioGenerationS
|
||||
# parents[0] = backend/api/podcast/
|
||||
# parents[1] = backend/api/
|
||||
# parents[2] = backend/
|
||||
BASE_DIR = Path(__file__).resolve().parents[2] # backend/
|
||||
PODCAST_AUDIO_DIR = (BASE_DIR / "podcast_audio").resolve()
|
||||
# parents[3] = root/
|
||||
ROOT_DIR = Path(__file__).resolve().parents[3] # root/
|
||||
DATA_MEDIA_DIR = ROOT_DIR / "data" / "media"
|
||||
|
||||
PODCAST_AUDIO_DIR = (DATA_MEDIA_DIR / "podcast_audio").resolve()
|
||||
PODCAST_AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PODCAST_IMAGES_DIR = (BASE_DIR / "podcast_images").resolve()
|
||||
PODCAST_IMAGES_DIR = (DATA_MEDIA_DIR / "podcast_images").resolve()
|
||||
PODCAST_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PODCAST_VIDEOS_DIR = (BASE_DIR / "podcast_videos").resolve()
|
||||
PODCAST_VIDEOS_DIR = (DATA_MEDIA_DIR / "podcast_videos").resolve()
|
||||
PODCAST_VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Video subdirectory
|
||||
|
||||
@@ -76,20 +76,22 @@ async def analyze_research_intent(
|
||||
|
||||
if request.use_persona or request.use_competitor_data:
|
||||
from services.research.research_persona_service import ResearchPersonaService
|
||||
from services.onboarding.database_service import OnboardingDatabaseService
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Get database session
|
||||
db = next(get_db())
|
||||
try:
|
||||
persona_service = ResearchPersonaService(db)
|
||||
onboarding_service = OnboardingDatabaseService(db=db)
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
|
||||
if request.use_persona:
|
||||
research_persona = persona_service.get_or_generate(user_id)
|
||||
|
||||
if request.use_competitor_data:
|
||||
competitor_data = onboarding_service.get_competitor_analysis(user_id, db)
|
||||
# Use SSOT integration service
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
|
||||
competitor_data = integrated_data.get('competitor_analysis', [])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ from pydantic import BaseModel
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from services.user_api_key_context import get_exa_key, get_gemini_key, get_tavily_key
|
||||
from services.onboarding.database_service import OnboardingDatabaseService
|
||||
from services.onboarding.progress_service import get_onboarding_progress_service
|
||||
from services.onboarding.progress_service import OnboardingProgressService
|
||||
from services.database import get_db
|
||||
from sqlalchemy.orm import Session
|
||||
from services.research.research_persona_service import ResearchPersonaService
|
||||
from services.research.research_persona_scheduler import schedule_research_persona_generation
|
||||
from models.research_persona_models import ResearchPersona
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -129,8 +129,6 @@ async def get_persona_defaults(
|
||||
# Return minimal defaults - but onboarding guarantees this won't happen
|
||||
return PersonaDefaults()
|
||||
|
||||
db_service = OnboardingDatabaseService(db=db)
|
||||
|
||||
# Phase 2: First check if research persona exists (cached only - don't generate here)
|
||||
# Generation happens in ResearchEngine.research() on first use
|
||||
research_persona = None
|
||||
@@ -178,36 +176,27 @@ async def get_persona_defaults(
|
||||
provider_recommendations=getattr(research_persona, 'provider_recommendations', {}),
|
||||
)
|
||||
|
||||
# Fallback to core persona from onboarding (guaranteed to exist after onboarding)
|
||||
persona_data = db_service.get_persona_data(user_id, db)
|
||||
industry = None
|
||||
target_audience = None
|
||||
|
||||
if persona_data:
|
||||
core_persona = persona_data.get('corePersona') or persona_data.get('core_persona')
|
||||
if core_persona:
|
||||
industry = core_persona.get('industry')
|
||||
target_audience = core_persona.get('target_audience')
|
||||
|
||||
# Fallback to website analysis if core persona doesn't have industry
|
||||
if not industry:
|
||||
website_analysis = db_service.get_website_analysis(user_id, db)
|
||||
if website_analysis:
|
||||
target_audience_data = website_analysis.get('target_audience', {})
|
||||
if isinstance(target_audience_data, dict):
|
||||
industry = target_audience_data.get('industry_focus')
|
||||
demographics = target_audience_data.get('demographics')
|
||||
if demographics and not target_audience:
|
||||
target_audience = demographics if isinstance(demographics, str) else str(demographics)
|
||||
|
||||
# Phase 2: Never return "General" - use sensible defaults from onboarding or fallback
|
||||
# Since onboarding is mandatory, we should always have real data
|
||||
if not industry:
|
||||
industry = "Technology" # Safe default for content creators
|
||||
logger.warning(f"[ResearchConfig] No industry found for user {user_id}, using default")
|
||||
if not target_audience:
|
||||
target_audience = "Professionals" # Safe default
|
||||
logger.warning(f"[ResearchConfig] No target_audience found for user {user_id}, using default")
|
||||
# Use SSOT Integration Service to get canonical profile
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
|
||||
canonical_profile = integrated_data.get('canonical_profile', {})
|
||||
|
||||
industry = canonical_profile.get('industry')
|
||||
target_audience_raw = canonical_profile.get('target_audience')
|
||||
|
||||
if isinstance(target_audience_raw, list):
|
||||
target_audience = ", ".join(str(item) for item in target_audience_raw if item is not None)
|
||||
elif isinstance(target_audience_raw, dict):
|
||||
target_audience = target_audience_raw.get('description') or target_audience_raw.get('label') or str(target_audience_raw)
|
||||
else:
|
||||
target_audience = target_audience_raw
|
||||
|
||||
if not industry or industry == "General":
|
||||
industry = "Technology"
|
||||
logger.warning(f"[ResearchConfig] No industry found in canonical profile for user {user_id}, using default")
|
||||
if not target_audience or target_audience == "General":
|
||||
target_audience = "Professionals and content consumers"
|
||||
logger.warning(f"[ResearchConfig] No target_audience found in canonical profile for user {user_id}, using default")
|
||||
|
||||
# Suggest domains based on industry
|
||||
suggested_domains = _get_domain_suggestions(industry)
|
||||
@@ -377,39 +366,21 @@ async def get_research_config(
|
||||
|
||||
# Get persona defaults
|
||||
logger.debug(f"[ResearchConfig] Getting persona defaults for user {user_id}")
|
||||
db_service = OnboardingDatabaseService(db=db)
|
||||
|
||||
# Try to get persona data first (most reliable source for industry/target_audience)
|
||||
try:
|
||||
persona_data = db_service.get_persona_data(user_id, db)
|
||||
except Exception as e:
|
||||
logger.error(f"[ResearchConfig] Error getting persona data for user {user_id}: {e}", exc_info=True)
|
||||
persona_data = None
|
||||
# Use SSOT Integration Service
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
|
||||
canonical_profile = integrated_data.get('canonical_profile', {})
|
||||
|
||||
industry = 'General'
|
||||
target_audience = 'General'
|
||||
industry = canonical_profile.get('industry') or 'General'
|
||||
target_audience_raw = canonical_profile.get('target_audience')
|
||||
|
||||
if persona_data:
|
||||
core_persona = persona_data.get('corePersona') or persona_data.get('core_persona')
|
||||
if core_persona:
|
||||
if core_persona.get('industry'):
|
||||
industry = core_persona['industry']
|
||||
if core_persona.get('target_audience'):
|
||||
target_audience = core_persona['target_audience']
|
||||
|
||||
# Fallback to website analysis if persona data doesn't have industry info
|
||||
if industry == 'General':
|
||||
website_analysis = db_service.get_website_analysis(user_id, db)
|
||||
if website_analysis:
|
||||
target_audience_data = website_analysis.get('target_audience', {})
|
||||
if isinstance(target_audience_data, dict):
|
||||
# Extract from target_audience JSON field
|
||||
industry_focus = target_audience_data.get('industry_focus')
|
||||
if industry_focus:
|
||||
industry = industry_focus
|
||||
demographics = target_audience_data.get('demographics')
|
||||
if demographics:
|
||||
target_audience = demographics if isinstance(demographics, str) else str(demographics)
|
||||
if isinstance(target_audience_raw, list):
|
||||
target_audience = ", ".join(str(item) for item in target_audience_raw if item is not None)
|
||||
elif isinstance(target_audience_raw, dict):
|
||||
target_audience = target_audience_raw.get('description') or target_audience_raw.get('label') or str(target_audience_raw)
|
||||
else:
|
||||
target_audience = target_audience_raw or 'General'
|
||||
|
||||
persona_defaults = PersonaDefaults(
|
||||
industry=industry,
|
||||
@@ -422,7 +393,7 @@ async def get_research_config(
|
||||
onboarding_completed = False
|
||||
try:
|
||||
logger.debug(f"[ResearchConfig] Checking onboarding status for user {user_id}")
|
||||
progress_service = get_onboarding_progress_service()
|
||||
progress_service = OnboardingProgressService()
|
||||
onboarding_status = progress_service.get_onboarding_status(user_id)
|
||||
onboarding_completed = onboarding_status.get('is_completed', False)
|
||||
logger.info(
|
||||
@@ -466,8 +437,10 @@ async def get_research_config(
|
||||
if onboarding_completed and not research_persona:
|
||||
try:
|
||||
# Check if persona data exists (to ensure we have data to generate from)
|
||||
db_service = OnboardingDatabaseService(db=db)
|
||||
persona_data = db_service.get_persona_data(user_id, db)
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
|
||||
persona_data = integrated_data.get('persona_data', {})
|
||||
|
||||
if persona_data and (persona_data.get('corePersona') or persona_data.get('platformPersonas') or
|
||||
persona_data.get('core_persona') or persona_data.get('platform_personas')):
|
||||
# Schedule persona generation (20 minutes from now)
|
||||
@@ -559,12 +532,16 @@ async def get_competitor_analysis(
|
||||
logger.error(f"[ResearchConfig] Database session is None for user {user_id}")
|
||||
raise HTTPException(status_code=500, detail="Database session not available")
|
||||
|
||||
db_service = OnboardingDatabaseService(db=db)
|
||||
# Use SSOT Integration Service
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
|
||||
|
||||
onboarding_session = integrated_data.get('onboarding_session')
|
||||
|
||||
# Get onboarding session - using same pattern as onboarding completion check
|
||||
print(f"[COMPETITOR_ANALYSIS] Looking up onboarding session for user_id={user_id} (Clerk ID)")
|
||||
session = db_service.get_session_by_user(user_id, db)
|
||||
if not session:
|
||||
|
||||
if not onboarding_session:
|
||||
print(f"[COMPETITOR_ANALYSIS] ❌ WARNING: No onboarding session found for user_id={user_id}")
|
||||
logger.warning(f"[ResearchConfig] No onboarding session found for user {user_id}")
|
||||
return CompetitorAnalysisResponse(
|
||||
@@ -572,30 +549,31 @@ async def get_competitor_analysis(
|
||||
error="No onboarding session found. Please complete onboarding first."
|
||||
)
|
||||
|
||||
print(f"[COMPETITOR_ANALYSIS] ✅ Found onboarding session: id={session.id}, user_id={session.user_id}, current_step={session.current_step}")
|
||||
print(f"[COMPETITOR_ANALYSIS] ✅ Found onboarding session: id={onboarding_session.get('id')}, user_id={onboarding_session.get('user_id')}, current_step={onboarding_session.get('current_step')}")
|
||||
|
||||
# Check if step 3 is completed - same pattern as elsewhere (check current_step >= 3 or research_preferences exists)
|
||||
research_preferences = db_service.get_research_preferences(user_id, db)
|
||||
print(f"[COMPETITOR_ANALYSIS] Step check: current_step={session.current_step}, research_preferences exists={research_preferences is not None}")
|
||||
if not research_preferences and session.current_step < 3:
|
||||
print(f"[COMPETITOR_ANALYSIS] ❌ Step 3 not completed for user_id={user_id} (current_step={session.current_step})")
|
||||
logger.info(f"[ResearchConfig] Step 3 not completed for user {user_id} (current_step={session.current_step})")
|
||||
research_preferences = integrated_data.get('research_preferences')
|
||||
current_step = onboarding_session.get('current_step', 0)
|
||||
|
||||
print(f"[COMPETITOR_ANALYSIS] Step check: current_step={current_step}, research_preferences exists={research_preferences is not None}")
|
||||
if not research_preferences and current_step < 3:
|
||||
print(f"[COMPETITOR_ANALYSIS] ❌ Step 3 not completed for user_id={user_id} (current_step={current_step})")
|
||||
logger.info(f"[ResearchConfig] Step 3 not completed for user {user_id} (current_step={current_step})")
|
||||
return CompetitorAnalysisResponse(
|
||||
success=False,
|
||||
error="Onboarding step 3 (Competitor Analysis) is not completed. Please complete onboarding step 3 first."
|
||||
)
|
||||
|
||||
print(f"[COMPETITOR_ANALYSIS] ✅ Step 3 is completed (current_step={session.current_step} or research_preferences exists)")
|
||||
print(f"[COMPETITOR_ANALYSIS] ✅ Step 3 is completed (current_step={current_step} or research_preferences exists)")
|
||||
|
||||
# Try Method 1: Get competitor data from CompetitorAnalysis table using OnboardingDatabaseService
|
||||
# This follows the same pattern as get_website_analysis()
|
||||
print(f"[COMPETITOR_ANALYSIS] 🔍 Method 1: Querying CompetitorAnalysis table using OnboardingDatabaseService...")
|
||||
# Try Method 1: Get competitor data from SSOT (Integration Service)
|
||||
print(f"[COMPETITOR_ANALYSIS] 🔍 Method 1: Querying via OnboardingDataIntegrationService...")
|
||||
try:
|
||||
competitors = db_service.get_competitor_analysis(user_id, db)
|
||||
competitors = integrated_data.get('competitor_analysis', [])
|
||||
|
||||
if competitors:
|
||||
print(f"[COMPETITOR_ANALYSIS] ✅ Found {len(competitors)} competitor records from CompetitorAnalysis table")
|
||||
logger.info(f"[ResearchConfig] Found {len(competitors)} competitors from CompetitorAnalysis table for user {user_id}")
|
||||
print(f"[COMPETITOR_ANALYSIS] ✅ Found {len(competitors)} competitor records from SSOT")
|
||||
logger.info(f"[ResearchConfig] Found {len(competitors)} competitors from SSOT for user {user_id}")
|
||||
|
||||
# Map competitor fields to match frontend expectations
|
||||
mapped_competitors = []
|
||||
@@ -621,13 +599,13 @@ async def get_competitor_analysis(
|
||||
analysis_timestamp=None
|
||||
)
|
||||
else:
|
||||
print(f"[COMPETITOR_ANALYSIS] ⚠️ No competitor records found in CompetitorAnalysis table for user_id={user_id}")
|
||||
print(f"[COMPETITOR_ANALYSIS] ⚠️ No competitor records found in SSOT for user_id={user_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[COMPETITOR_ANALYSIS] ❌ EXCEPTION in Method 1: {e}")
|
||||
import traceback
|
||||
print(f"[COMPETITOR_ANALYSIS] Traceback:\n{traceback.format_exc()}")
|
||||
logger.warning(f"[ResearchConfig] Could not retrieve competitor data from CompetitorAnalysis table: {e}", exc_info=True)
|
||||
logger.warning(f"[ResearchConfig] Could not retrieve competitor data from SSOT: {e}", exc_info=True)
|
||||
|
||||
# Try Method 2: Get data from Step3ResearchService (which accesses step_data)
|
||||
# This is where step3_research_service._store_research_data() saves the data
|
||||
@@ -734,18 +712,21 @@ async def refresh_competitor_analysis(
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database session not available")
|
||||
|
||||
db_service = OnboardingDatabaseService(db=db)
|
||||
# Use SSOT Integration Service
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
|
||||
|
||||
onboarding_session = integrated_data.get('onboarding_session')
|
||||
|
||||
# Get onboarding session
|
||||
session = db_service.get_session_by_user(user_id, db)
|
||||
if not session:
|
||||
if not onboarding_session:
|
||||
return CompetitorAnalysisResponse(
|
||||
success=False,
|
||||
error="No onboarding session found. Please complete onboarding first."
|
||||
)
|
||||
|
||||
# Get website URL from website analysis
|
||||
website_analysis = db_service.get_website_analysis(user_id, db)
|
||||
website_analysis = integrated_data.get('website_analysis') or {}
|
||||
if not website_analysis or not website_analysis.get('website_url'):
|
||||
return CompetitorAnalysisResponse(
|
||||
success=False,
|
||||
@@ -760,8 +741,8 @@ async def refresh_competitor_analysis(
|
||||
)
|
||||
|
||||
# Get industry context from research preferences or persona
|
||||
research_prefs = db_service.get_research_preferences(user_id, db) or {}
|
||||
persona_data = db_service.get_persona_data(user_id, db) or {}
|
||||
research_prefs = integrated_data.get('research_preferences') or {}
|
||||
persona_data = integrated_data.get('persona_data') or {}
|
||||
core_persona = persona_data.get('corePersona') or persona_data.get('core_persona') or {}
|
||||
industry_context = core_persona.get('industry') or research_prefs.get('industry') or None
|
||||
|
||||
@@ -778,8 +759,10 @@ async def refresh_competitor_analysis(
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
# Get the updated competitor data from database
|
||||
competitors = db_service.get_competitor_analysis(user_id, db)
|
||||
# Get the updated competitor data from SSOT (Integration Service)
|
||||
# Re-fetch integrated data to get the latest updates
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db)
|
||||
competitors = integrated_data.get('competitor_analysis', [])
|
||||
|
||||
if competitors:
|
||||
# Map competitor fields
|
||||
|
||||
@@ -19,7 +19,7 @@ from models.monitoring_models import TaskExecutionLog, MonitoringTask
|
||||
from models.scheduler_models import SchedulerEventLog
|
||||
from models.oauth_token_monitoring_models import OAuthTokenMonitoringTask
|
||||
from models.platform_insights_monitoring_models import PlatformInsightsTask, PlatformInsightsExecutionLog
|
||||
from models.website_analysis_monitoring_models import WebsiteAnalysisTask, WebsiteAnalysisExecutionLog
|
||||
from models.website_analysis_monitoring_models import WebsiteAnalysisTask, WebsiteAnalysisExecutionLog, DeepWebsiteCrawlTask
|
||||
|
||||
router = APIRouter(prefix="/api/scheduler", tags=["scheduler-dashboard"])
|
||||
|
||||
@@ -271,6 +271,43 @@ async def get_scheduler_dashboard(
|
||||
formatted_jobs.append(job_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading platform insights tasks: {e}", exc_info=True)
|
||||
|
||||
# Load deep website crawl tasks
|
||||
try:
|
||||
crawl_tasks = db.query(DeepWebsiteCrawlTask).filter(
|
||||
DeepWebsiteCrawlTask.status.in_(['active', 'retry'])
|
||||
).all()
|
||||
|
||||
# Filter by user if user_id_str is provided
|
||||
if user_id_str:
|
||||
crawl_tasks = [t for t in crawl_tasks if t.user_id == user_id_str]
|
||||
|
||||
for task in crawl_tasks:
|
||||
try:
|
||||
user_job_store = get_user_job_store_name(task.user_id, db)
|
||||
except Exception as e:
|
||||
user_job_store = 'default'
|
||||
logger.debug(f"Could not get job store for user {task.user_id}: {e}")
|
||||
|
||||
# Format as recurring weekly job
|
||||
job_info = {
|
||||
'id': f"deep_website_crawl_{task.user_id}_{task.id}",
|
||||
'trigger_type': 'CronTrigger', # Weekly recurring
|
||||
'next_run_time': task.next_execution.isoformat() if task.next_execution else None,
|
||||
'user_id': task.user_id,
|
||||
'job_store': 'default',
|
||||
'user_job_store': user_job_store,
|
||||
'function_name': 'deep_website_crawl_executor.execute_task',
|
||||
'website_url': task.website_url,
|
||||
'task_id': task.id,
|
||||
'is_database_task': True,
|
||||
'frequency': 'Weekly',
|
||||
'task_category': 'deep_website_crawl'
|
||||
}
|
||||
|
||||
formatted_jobs.append(job_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading deep website crawl tasks: {e}", exc_info=True)
|
||||
|
||||
# Get active strategies count
|
||||
active_strategies = stats.get('active_strategies_count', 0)
|
||||
|
||||
@@ -14,9 +14,16 @@ from services.onboarding.api_key_manager import APIKeyManager
|
||||
from services.validation import check_all_api_keys
|
||||
from services.seo_analyzer import ComprehensiveSEOAnalyzer, SEOAnalysisResult, SEOAnalysisService
|
||||
from services.user_data_service import UserDataService
|
||||
from services.database import get_db_session
|
||||
from services.database import get_db_session, get_session_for_user
|
||||
from services.seo import SEODashboardService
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
from models.onboarding import SEOPageAudit, WebsiteAnalysis, OnboardingSession
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
# Phase 2B: Import semantic monitoring
|
||||
from services.intelligence.monitoring.semantic_dashboard import RealTimeSemanticMonitor, SemanticHealthMetric
|
||||
|
||||
# Initialize the SEO analyzer
|
||||
seo_analyzer = ComprehensiveSEOAnalyzer()
|
||||
@@ -64,6 +71,9 @@ class SEOAnalysisRequest(BaseModel):
|
||||
url: str
|
||||
target_keywords: Optional[List[str]] = None
|
||||
|
||||
class AnalyzeURLsRequest(BaseModel):
|
||||
urls: List[str]
|
||||
|
||||
class SEOAnalysisResponse(BaseModel):
|
||||
url: str
|
||||
timestamp: datetime
|
||||
@@ -239,12 +249,105 @@ def generate_ai_insights(metrics: Dict[str, Any], platforms: Dict[str, Any]) ->
|
||||
|
||||
return insights
|
||||
|
||||
from services.seo.deep_competitor_analysis_service import DeepCompetitorAnalysisService
|
||||
|
||||
# API Endpoints
|
||||
async def run_strategic_insights(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Manually trigger AI-Powered Competitive Insights (Weekly Strategy Brief).
|
||||
"""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session(user_id)
|
||||
|
||||
if not db_session:
|
||||
raise HTTPException(status_code=500, detail="Database connection unavailable")
|
||||
|
||||
try:
|
||||
# 1. Get Website Analysis (with fallback)
|
||||
website_analysis_data = None
|
||||
analysis_id = None
|
||||
|
||||
# Try SSOT first
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated_data = integration_service.get_integrated_data_sync(user_id, db_session)
|
||||
if integrated_data and integrated_data.get("website_analysis"):
|
||||
website_analysis_data = integrated_data.get("website_analysis")
|
||||
analysis_id = website_analysis_data.get("id")
|
||||
|
||||
# Fallback: Find latest WebsiteAnalysis across sessions
|
||||
if not website_analysis_data:
|
||||
latest_analysis = db_session.query(WebsiteAnalysis).join(
|
||||
OnboardingSession, WebsiteAnalysis.session_id == OnboardingSession.id
|
||||
).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).order_by(WebsiteAnalysis.updated_at.desc()).first()
|
||||
|
||||
if latest_analysis:
|
||||
# Convert to dict
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
website_analysis_data = jsonable_encoder(latest_analysis)
|
||||
analysis_id = latest_analysis.id
|
||||
|
||||
if not website_analysis_data:
|
||||
raise HTTPException(status_code=400, detail="No website analysis found. Please complete Onboarding Step 2.")
|
||||
|
||||
# 2. Get Competitors
|
||||
competitors = []
|
||||
if integrated_data:
|
||||
competitors = integrated_data.get("competitor_analysis", [])
|
||||
|
||||
if not competitors:
|
||||
# Fallback to research preferences
|
||||
research_prefs = integrated_data.get("research_preferences", {})
|
||||
competitors = research_prefs.get("competitors", [])
|
||||
|
||||
if not competitors:
|
||||
raise HTTPException(status_code=400, detail="No competitors found. Please complete Onboarding Step 3.")
|
||||
|
||||
# 3. Run Analysis
|
||||
service = DeepCompetitorAnalysisService()
|
||||
report = await service.generate_weekly_strategy_brief(
|
||||
user_id=user_id,
|
||||
website_analysis=website_analysis_data,
|
||||
competitors=competitors
|
||||
)
|
||||
|
||||
# 4. Persist to History
|
||||
if analysis_id:
|
||||
wa = db_session.query(WebsiteAnalysis).filter(WebsiteAnalysis.id == analysis_id).first()
|
||||
if wa:
|
||||
history = wa.strategic_insights_history or []
|
||||
# Ensure history is a list
|
||||
if not isinstance(history, list):
|
||||
history = []
|
||||
|
||||
# Prepend new report
|
||||
history.insert(0, report)
|
||||
|
||||
# Keep last 52 weeks
|
||||
wa.strategic_insights_history = history[:52]
|
||||
flag_modified(wa, "strategic_insights_history")
|
||||
db_session.commit()
|
||||
|
||||
return report
|
||||
|
||||
finally:
|
||||
db_session.close()
|
||||
|
||||
except HTTPException as he:
|
||||
raise he
|
||||
except Exception as e:
|
||||
logger.error(f"Error running strategic insights: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to run analysis: {str(e)}")
|
||||
|
||||
async def get_seo_dashboard_data(current_user: dict = Depends(get_current_user)) -> SEODashboardData:
|
||||
"""Get comprehensive SEO dashboard data."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
|
||||
if not db_session:
|
||||
logger.error("No database session available")
|
||||
@@ -278,7 +381,7 @@ async def get_seo_health_score(current_user: dict = Depends(get_current_user)) -
|
||||
"""Get current SEO health score."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
|
||||
if not db_session:
|
||||
raise HTTPException(status_code=500, detail="Database connection unavailable")
|
||||
@@ -299,7 +402,7 @@ async def get_seo_metrics(current_user: dict = Depends(get_current_user)) -> Dic
|
||||
"""Get SEO metrics."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
|
||||
if not db_session:
|
||||
raise HTTPException(status_code=500, detail="Database connection unavailable")
|
||||
@@ -322,7 +425,7 @@ async def get_platform_status(
|
||||
"""Get platform connection status."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
|
||||
if not db_session:
|
||||
logger.error("No database session available")
|
||||
@@ -347,7 +450,7 @@ async def get_ai_insights(current_user: dict = Depends(get_current_user)) -> Lis
|
||||
"""Get AI-generated insights."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
|
||||
if not db_session:
|
||||
raise HTTPException(status_code=500, detail="Database connection unavailable")
|
||||
@@ -368,6 +471,59 @@ async def seo_dashboard_health_check():
|
||||
"""Health check for SEO dashboard."""
|
||||
return {"status": "healthy", "service": "SEO Dashboard API"}
|
||||
|
||||
# Phase 2B: Semantic health monitoring endpoint
|
||||
async def get_semantic_health(current_user: dict = Depends(get_current_user)) -> SemanticHealthMetric:
|
||||
"""
|
||||
Get real-time semantic health metrics for the user's content and competitors.
|
||||
This endpoint provides Phase 2B semantic intelligence monitoring data.
|
||||
|
||||
Returns:
|
||||
SemanticHealthMetric with current health status, score, and recommendations
|
||||
"""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
|
||||
# Initialize semantic monitor for this user
|
||||
semantic_monitor = RealTimeSemanticMonitor(user_id)
|
||||
|
||||
# Get current semantic health (will use cache if available)
|
||||
semantic_health = await semantic_monitor.check_semantic_health(user_id)
|
||||
|
||||
logger.info(f"[Semantic Health API] Retrieved health data for user {user_id}: {semantic_health.status} (score: {semantic_health.value:.2f})")
|
||||
|
||||
return semantic_health
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Semantic Health API] Error retrieving semantic health for user: {e}")
|
||||
# Return a default healthy state with warning message
|
||||
return SemanticHealthMetric(
|
||||
metric_name="semantic_health",
|
||||
value=0.5,
|
||||
threshold=0.6,
|
||||
status="warning",
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
description="Semantic monitoring temporarily unavailable",
|
||||
recommendations=["Please try again later", "Check system status"]
|
||||
)
|
||||
|
||||
|
||||
async def get_semantic_cache_stats(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics for the semantic cache.
|
||||
"""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
# Initialize semantic monitor to access its cache manager
|
||||
semantic_monitor = RealTimeSemanticMonitor(user_id)
|
||||
return await semantic_monitor.get_cache_stats()
|
||||
except Exception as e:
|
||||
logger.error(f"[Semantic Cache API] Error retrieving cache stats: {e}")
|
||||
return {
|
||||
"error": "Failed to retrieve cache statistics",
|
||||
"hit_rate": 0.0,
|
||||
"memory_usage_mb": 0.0
|
||||
}
|
||||
|
||||
# New comprehensive SEO analysis endpoints
|
||||
async def analyze_seo_comprehensive(request: SEOAnalysisRequest) -> SEOAnalysisResponse:
|
||||
"""
|
||||
@@ -650,6 +806,107 @@ async def batch_analyze_urls(urls: List[str]) -> Dict[str, Any]:
|
||||
detail=f"Error in batch analysis: {str(e)}"
|
||||
)
|
||||
|
||||
async def analyze_urls_ai(request: AnalyzeURLsRequest, current_user: dict) -> Dict[str, Any]:
|
||||
"""Run AI analysis on selected URLs."""
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session()
|
||||
results = []
|
||||
|
||||
try:
|
||||
for url in request.urls:
|
||||
# Check if audit exists
|
||||
audit = db_session.query(SEOPageAudit).filter(
|
||||
SEOPageAudit.user_id == user_id,
|
||||
SEOPageAudit.page_url == url
|
||||
).first()
|
||||
|
||||
if not audit:
|
||||
results.append({"url": url, "status": "skipped", "reason": "No audit found"})
|
||||
continue
|
||||
|
||||
# Prepare Prompt
|
||||
# We use the existing audit data (algorithmic) to feed the AI
|
||||
audit_summary = {
|
||||
"score": audit.overall_score,
|
||||
"issues": audit.issues,
|
||||
"warnings": audit.warnings
|
||||
}
|
||||
|
||||
prompt = f"""
|
||||
As an expert SEO consultant, analyze these technical audit results for the page: {url}
|
||||
|
||||
AUDIT DATA:
|
||||
{json.dumps(audit_summary, default=str)[:3000]}
|
||||
|
||||
TASK:
|
||||
Provide 3 specific, high-impact AI recommendations to improve this page's SEO.
|
||||
Focus on content relevance, user intent, and semantic SEO, which the algorithmic audit might miss.
|
||||
|
||||
OUTPUT JSON format:
|
||||
[
|
||||
{{ "category": "Content|Technical|UX", "recommendation": "...", "impact": "High|Medium", "effort": "Low|Medium" }}
|
||||
]
|
||||
"""
|
||||
|
||||
try:
|
||||
ai_response = llm_text_gen(prompt, user_id=user_id)
|
||||
# Parse JSON
|
||||
import re
|
||||
cleaned = ai_response.strip().replace("```json", "").replace("```", "")
|
||||
# Simple regex to find the JSON array if extra text exists
|
||||
match = re.search(r'\[.*\]', cleaned, re.DOTALL)
|
||||
if match:
|
||||
cleaned = match.group(0)
|
||||
|
||||
recommendations = json.loads(cleaned)
|
||||
|
||||
# Update audit
|
||||
current_recs = audit.recommendations or []
|
||||
if isinstance(current_recs, list):
|
||||
# Tag new ones
|
||||
for r in recommendations:
|
||||
r['source'] = 'ai_on_demand'
|
||||
current_recs.extend(recommendations)
|
||||
audit.recommendations = current_recs
|
||||
|
||||
audit.last_analyzed_at = datetime.utcnow()
|
||||
results.append({"url": url, "status": "success"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI Analysis failed for {url}: {e}")
|
||||
results.append({"url": url, "status": "failed", "error": str(e)})
|
||||
|
||||
db_session.commit()
|
||||
return {"results": results}
|
||||
|
||||
finally:
|
||||
db_session.close()
|
||||
|
||||
async def get_analyzed_pages(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
"""Get list of pages that have been analyzed by AI."""
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session()
|
||||
|
||||
try:
|
||||
audits = db_session.query(SEOPageAudit).filter(
|
||||
SEOPageAudit.user_id == user_id
|
||||
).all()
|
||||
|
||||
results = []
|
||||
for audit in audits:
|
||||
if audit.recommendations:
|
||||
results.append({
|
||||
"url": audit.page_url,
|
||||
"analyzed_at": audit.last_analyzed_at,
|
||||
"score": audit.overall_score,
|
||||
"recommendations_count": len(audit.recommendations)
|
||||
})
|
||||
|
||||
return {"results": results}
|
||||
finally:
|
||||
db_session.close()
|
||||
|
||||
|
||||
# New SEO Dashboard Endpoints with Real Data
|
||||
|
||||
async def get_seo_dashboard_overview(
|
||||
@@ -659,7 +916,7 @@ async def get_seo_dashboard_overview(
|
||||
"""Get comprehensive SEO dashboard overview with real GSC/Bing data."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session()
|
||||
db_session = get_session_for_user(user_id)
|
||||
|
||||
if not db_session:
|
||||
logger.error("No database session available")
|
||||
@@ -715,7 +972,7 @@ async def get_bing_raw_data(
|
||||
"""Get raw Bing data for the specified site."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
|
||||
if not db_session:
|
||||
logger.error("No database session available")
|
||||
@@ -743,7 +1000,7 @@ async def get_competitive_insights(
|
||||
"""Get competitive insights from onboarding step 3 data."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
|
||||
if not db_session:
|
||||
logger.error("No database session available")
|
||||
@@ -764,6 +1021,153 @@ async def get_competitive_insights(
|
||||
logger.error(f"Error getting competitive insights: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get competitive insights")
|
||||
|
||||
|
||||
async def get_deep_competitor_analysis(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
site_url: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_session_for_user(user_id)
|
||||
|
||||
if not db_session:
|
||||
logger.error("No database session available")
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
try:
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated = integration_service.get_integrated_data_sync(user_id, db_session)
|
||||
deep = integrated.get("deep_competitor_analysis") if isinstance(integrated, dict) else None
|
||||
return deep or {
|
||||
"status": "not_available",
|
||||
"last_run": None,
|
||||
"report": None
|
||||
}
|
||||
finally:
|
||||
db_session.close()
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting deep competitor analysis: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get deep competitor analysis")
|
||||
|
||||
|
||||
async def run_strategic_insights(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""Run AI-powered strategic insights analysis manually."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_session_for_user(user_id)
|
||||
|
||||
if not db_session:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
try:
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated = integration_service.get_integrated_data_sync(user_id, db_session)
|
||||
|
||||
website_analysis_data = integrated.get("website_analysis")
|
||||
logger.info(f"Integrated data for user {user_id}: website_analysis found? {bool(website_analysis_data)}")
|
||||
|
||||
# Fallback: If not found in integrated data (e.g. strict session mismatch), find latest analysis for user
|
||||
if not website_analysis_data:
|
||||
logger.info(f"Attempting fallback for user {user_id}")
|
||||
# Find latest WebsiteAnalysis for this user across all sessions
|
||||
latest_analysis = db_session.query(WebsiteAnalysis).join(
|
||||
OnboardingSession, WebsiteAnalysis.session_id == OnboardingSession.id
|
||||
).filter(
|
||||
OnboardingSession.user_id == user_id
|
||||
).order_by(WebsiteAnalysis.updated_at.desc()).first()
|
||||
|
||||
if latest_analysis:
|
||||
logger.info(f"Found fallback WebsiteAnalysis {latest_analysis.id} for user {user_id}")
|
||||
website_analysis_data = latest_analysis.to_dict()
|
||||
# Ensure ID is present for updates
|
||||
website_analysis_data['id'] = latest_analysis.id
|
||||
else:
|
||||
logger.warning(f"Fallback failed for user {user_id}. No WebsiteAnalysis found.")
|
||||
|
||||
if not website_analysis_data:
|
||||
raise HTTPException(status_code=400, detail="Website analysis (Step 2) not found. Please complete onboarding.")
|
||||
|
||||
research_prefs = integrated.get("research_preferences")
|
||||
competitors = (research_prefs.get("competitors") if isinstance(research_prefs, dict) else None)
|
||||
|
||||
if not competitors:
|
||||
# Try competitor_analysis as fallback
|
||||
competitors = integrated.get("competitor_analysis") or []
|
||||
|
||||
if not competitors:
|
||||
raise HTTPException(status_code=400, detail="No competitors found. Please add competitors in Step 3.")
|
||||
|
||||
from services.seo.deep_competitor_analysis_service import DeepCompetitorAnalysisService
|
||||
analysis_service = DeepCompetitorAnalysisService()
|
||||
|
||||
logger.info(f"Running manual strategic insights for user {user_id}")
|
||||
report = await analysis_service.generate_weekly_strategy_brief(
|
||||
user_id=user_id,
|
||||
website_analysis=website_analysis_data if isinstance(website_analysis_data, dict) else {},
|
||||
competitors=competitors if isinstance(competitors, list) else []
|
||||
)
|
||||
|
||||
# Find the WebsiteAnalysis record to persist history
|
||||
analysis_id = website_analysis_data.get('id') if isinstance(website_analysis_data, dict) else None
|
||||
if analysis_id:
|
||||
website_analysis = db_session.query(WebsiteAnalysis).filter(WebsiteAnalysis.id == analysis_id).first()
|
||||
|
||||
if website_analysis:
|
||||
history = website_analysis.strategic_insights_history or []
|
||||
if not isinstance(history, list):
|
||||
history = []
|
||||
|
||||
# Append new report at the beginning (latest first)
|
||||
history.insert(0, report)
|
||||
# Keep last 52 weeks (1 year)
|
||||
website_analysis.strategic_insights_history = history[:52]
|
||||
flag_modified(website_analysis, "strategic_insights_history")
|
||||
db_session.commit()
|
||||
logger.info(f"Persisted strategic insight for user {user_id} to history")
|
||||
|
||||
return {"success": True, "report": report}
|
||||
finally:
|
||||
db_session.close()
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error running strategic insights: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to run strategic insights: {str(e)}")
|
||||
|
||||
|
||||
async def get_strategic_insights_history(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch the history of strategic insights for the user."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_session_for_user(user_id)
|
||||
|
||||
if not db_session:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
try:
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated = integration_service.get_integrated_data_sync(user_id, db_session)
|
||||
|
||||
website_analysis = integrated.get("website_analysis")
|
||||
if not website_analysis or not isinstance(website_analysis, dict):
|
||||
return {"history": []}
|
||||
|
||||
history = website_analysis.get("strategic_insights_history") or []
|
||||
return {"history": history}
|
||||
finally:
|
||||
db_session.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching strategic insights history: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch strategic insights history")
|
||||
|
||||
async def refresh_analytics_data(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
site_url: Optional[str] = None
|
||||
@@ -771,7 +1175,7 @@ async def refresh_analytics_data(
|
||||
"""Refresh analytics data by invalidating cache and fetching fresh data."""
|
||||
try:
|
||||
user_id = str(current_user.get('id'))
|
||||
db_session = get_db_session()
|
||||
db_session = get_db_session(user_id)
|
||||
|
||||
if not db_session:
|
||||
logger.error("No database session available")
|
||||
@@ -849,4 +1253,4 @@ def _convert_platforms(platform_data: Dict[str, Any]) -> Dict[str, PlatformStatu
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting platforms: {e}")
|
||||
return {}
|
||||
return {}
|
||||
|
||||
@@ -26,7 +26,7 @@ from services.story_writer.audio_generation_service import StoryAudioGenerationS
|
||||
from utils.asset_tracker import save_asset_to_library
|
||||
|
||||
from ..utils.auth import require_authenticated_user
|
||||
from ..utils.media_utils import resolve_media_file
|
||||
from ..utils.media_utils import resolve_media_file, resolve_story_media_path
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -57,6 +57,7 @@ async def generate_scene_images(
|
||||
width=request.width or 1024,
|
||||
height=request.height or 1024,
|
||||
model=request.model,
|
||||
db=db,
|
||||
)
|
||||
|
||||
image_models: List[StoryImageResult] = [
|
||||
|
||||
@@ -94,7 +94,7 @@ async def animate_scene_preview(
|
||||
request.image_url,
|
||||
)
|
||||
|
||||
image_bytes = load_story_image_bytes(request.image_url)
|
||||
image_bytes = load_story_image_bytes(request.image_url, user_id=user_id)
|
||||
if not image_bytes:
|
||||
scene_logger.warning("[AnimateScene] Missing image bytes for user=%s scene=%s", user_id, request.scene_number)
|
||||
raise HTTPException(status_code=404, detail="Scene image not found. Generate images first.")
|
||||
@@ -114,29 +114,35 @@ async def animate_scene_preview(
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
ai_video_dir = base_dir / "story_videos" / AI_VIDEO_SUBDIR
|
||||
ai_video_dir.mkdir(parents=True, exist_ok=True)
|
||||
video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir))
|
||||
|
||||
save_result = video_service.save_scene_video(
|
||||
video_bytes=animation_result["video_bytes"],
|
||||
scene_number=request.scene_number,
|
||||
user_id=user_id,
|
||||
)
|
||||
video_filename = save_result["video_filename"]
|
||||
video_url = _build_authenticated_media_url(
|
||||
request_obj, f"/api/story/videos/ai/{video_filename}"
|
||||
)
|
||||
|
||||
usage_info = track_video_usage(
|
||||
user_id=user_id,
|
||||
provider=animation_result["provider"],
|
||||
model_name=animation_result["model_name"],
|
||||
prompt=animation_result["prompt"],
|
||||
video_bytes=animation_result["video_bytes"],
|
||||
cost_override=animation_result["cost"],
|
||||
)
|
||||
# Save video asset to library
|
||||
db = next(get_db())
|
||||
try:
|
||||
video_service = StoryVideoGenerationService(output_dir=str(ai_video_dir))
|
||||
|
||||
save_result = video_service.save_scene_video(
|
||||
video_bytes=animation_result["video_bytes"],
|
||||
scene_number=request.scene_number,
|
||||
user_id=user_id,
|
||||
db=db
|
||||
)
|
||||
video_filename = save_result["video_filename"]
|
||||
video_url = _build_authenticated_media_url(
|
||||
request_obj, f"/api/story/videos/ai/{video_filename}"
|
||||
)
|
||||
|
||||
usage_info = track_video_usage(
|
||||
user_id=user_id,
|
||||
provider=animation_result["provider"],
|
||||
model_name=animation_result["model_name"],
|
||||
prompt=animation_result["prompt"],
|
||||
video_bytes=animation_result["video_bytes"],
|
||||
cost_override=animation_result["cost"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to track usage for generated video: {e}")
|
||||
# Don't fail the request if tracking fails, just log it
|
||||
pass
|
||||
|
||||
if usage_info:
|
||||
scene_logger.warning(
|
||||
"[AnimateScene] Video usage tracked user=%s: %s → %s / %s (cost +$%.2f, total=$%.2f)",
|
||||
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
@@ -88,8 +88,8 @@ async def generate_story_video(
|
||||
valid_scenes: List[Dict[str, Any]] = []
|
||||
|
||||
# Resolve video/audio directories
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
ai_video_dir = (base_dir / "story_videos" / "AI_Videos").resolve()
|
||||
base_dir = Path(__file__).resolve().parents[4]
|
||||
ai_video_dir = (base_dir / "data" / "media" / "story_videos" / "AI_Videos").resolve()
|
||||
|
||||
video_urls = request.video_urls or [None] * len(request.scenes)
|
||||
ai_audio_urls = request.ai_audio_urls or [None] * len(request.scenes)
|
||||
|
||||
@@ -7,15 +7,91 @@ from urllib.parse import urlparse
|
||||
from fastapi import HTTPException, status
|
||||
from loguru import logger
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[3] # backend/
|
||||
STORY_IMAGES_DIR = (BASE_DIR / "story_images").resolve()
|
||||
STORY_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STORY_AUDIO_DIR = (BASE_DIR / "story_audio").resolve()
|
||||
STORY_AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
from services.database import get_db
|
||||
from services.user_workspace_manager import UserWorkspaceManager
|
||||
|
||||
|
||||
def load_story_image_bytes(image_url: str) -> Optional[bytes]:
|
||||
BASE_DIR = Path(__file__).resolve().parents[4] # root/
|
||||
DATA_MEDIA_DIR = BASE_DIR / "workspace" / "media"
|
||||
|
||||
STORY_IMAGES_DIR = (DATA_MEDIA_DIR / "story_images").resolve()
|
||||
# STORY_IMAGES_DIR.mkdir(parents=True, exist_ok=True) # Disabled global creation
|
||||
|
||||
STORY_AUDIO_DIR = (DATA_MEDIA_DIR / "story_audio").resolve()
|
||||
# STORY_AUDIO_DIR.mkdir(parents=True, exist_ok=True) # Disabled global creation
|
||||
|
||||
|
||||
def _get_user_media_path(user_id: str, media_type: str) -> Optional[Path]:
|
||||
"""Resolve user-specific media directory."""
|
||||
try:
|
||||
# We need a new session for this operation
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
workspace_manager = UserWorkspaceManager(db)
|
||||
workspace = workspace_manager.get_user_workspace(user_id)
|
||||
if workspace:
|
||||
# media/story_images or media/story_audio
|
||||
subdir = "story_images" if media_type == "image" else "story_audio"
|
||||
path = Path(workspace['workspace_path']) / "media" / subdir
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
finally:
|
||||
# Ensure we close the session if it's not managed by dependency injection
|
||||
# Since get_db yields, we can't easily close it unless we manage the generator
|
||||
# But get_db uses SessionLocal() which should be closed.
|
||||
# However, get_db is a generator. We should really use a context manager or dependency.
|
||||
# Here we just took next(db), so it's an open session.
|
||||
# We should probably close it.
|
||||
# Actually, UserWorkspaceManager uses the passed db.
|
||||
# Let's assume standard usage pattern for manual DB access.
|
||||
pass
|
||||
# Note: The generator usage here is a bit tricky for cleanup.
|
||||
# Ideally we'd have a context manager.
|
||||
# For now, let's rely on garbage collection or explicit close if possible.
|
||||
# But SQLAlchemy sessions should be closed.
|
||||
# db.close() # valid if db is Session
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to resolve user workspace path for {user_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def resolve_story_media_path(filename: str, media_type: str, user_id: Optional[str] = None) -> Path:
|
||||
"""
|
||||
Resolve a story media file path, checking user workspace first then global directory.
|
||||
media_type: 'image' or 'audio'
|
||||
"""
|
||||
filename = filename.split("?")[0].strip()
|
||||
|
||||
# 1. Try user workspace
|
||||
if user_id:
|
||||
user_path = _get_user_media_path(user_id, media_type)
|
||||
if user_path:
|
||||
file_path = (user_path / filename).resolve()
|
||||
# Guard against traversal
|
||||
if str(file_path).startswith(str(user_path)) and file_path.exists():
|
||||
return file_path
|
||||
|
||||
# 2. Fallback to global directory
|
||||
base_dir = STORY_IMAGES_DIR if media_type == "image" else STORY_AUDIO_DIR
|
||||
file_path = (base_dir / filename).resolve()
|
||||
|
||||
if not str(file_path).startswith(str(base_dir)):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
if file_path.exists():
|
||||
return file_path
|
||||
|
||||
# 3. If not found, try alternate in global (legacy behavior support)
|
||||
alternate = _find_alternate_media_file(base_dir, filename)
|
||||
if alternate:
|
||||
logger.warning(f"[StoryWriter] Serving alternate media for {filename}: {alternate.name}")
|
||||
return alternate
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"File not found: {filename}")
|
||||
|
||||
|
||||
def load_story_image_bytes(image_url: str, user_id: Optional[str] = None) -> Optional[bytes]:
|
||||
"""
|
||||
Resolve an authenticated story image URL (e.g., /api/story/images/<file>) to raw bytes.
|
||||
Returns None if the file cannot be located.
|
||||
@@ -35,22 +111,21 @@ def load_story_image_bytes(image_url: str) -> Optional[bytes]:
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
file_path = (STORY_IMAGES_DIR / filename).resolve()
|
||||
if not str(file_path).startswith(str(STORY_IMAGES_DIR)):
|
||||
logger.error(f"[StoryWriter] Attempted path traversal when resolving image: {image_url}")
|
||||
# Try to resolve path using helper
|
||||
try:
|
||||
file_path = resolve_story_media_path(filename, "image", user_id)
|
||||
return file_path.read_bytes()
|
||||
except HTTPException:
|
||||
# Not found
|
||||
logger.warning(f"[StoryWriter] Referenced scene image not found: {filename}")
|
||||
return None
|
||||
|
||||
if not file_path.exists():
|
||||
logger.warning(f"[StoryWriter] Referenced scene image not found on disk: {file_path}")
|
||||
return None
|
||||
|
||||
return file_path.read_bytes()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"[StoryWriter] Failed to load reference image for video gen: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def load_story_audio_bytes(audio_url: str) -> Optional[bytes]:
|
||||
def load_story_audio_bytes(audio_url: str, user_id: Optional[str] = None) -> Optional[bytes]:
|
||||
"""
|
||||
Resolve an authenticated story audio URL (e.g., /api/story/audio/<file>) to raw bytes.
|
||||
Returns None if the file cannot be located.
|
||||
@@ -70,16 +145,15 @@ def load_story_audio_bytes(audio_url: str) -> Optional[bytes]:
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
file_path = (STORY_AUDIO_DIR / filename).resolve()
|
||||
if not str(file_path).startswith(str(STORY_AUDIO_DIR)):
|
||||
logger.error(f"[StoryWriter] Attempted path traversal when resolving audio: {audio_url}")
|
||||
# Try to resolve path using helper
|
||||
try:
|
||||
file_path = resolve_story_media_path(filename, "audio", user_id)
|
||||
return file_path.read_bytes()
|
||||
except HTTPException:
|
||||
# Not found
|
||||
logger.warning(f"[StoryWriter] Referenced scene audio not found: {filename}")
|
||||
return None
|
||||
|
||||
if not file_path.exists():
|
||||
logger.warning(f"[StoryWriter] Referenced scene audio not found on disk: {file_path}")
|
||||
return None
|
||||
|
||||
return file_path.read_bytes()
|
||||
except Exception as exc:
|
||||
logger.error(f"[StoryWriter] Failed to load reference audio for video gen: {exc}")
|
||||
return None
|
||||
|
||||
@@ -63,8 +63,17 @@ async def get_usage_alerts(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting usage alerts: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error(f"Error getting usage alerts: {e}", exc_info=True)
|
||||
# Return empty alerts instead of 500
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"alerts": [],
|
||||
"total": 0,
|
||||
"unread_count": 0,
|
||||
"message": f"Error retrieving alerts: {str(e)}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/alerts/{alert_id}/mark-read")
|
||||
|
||||
@@ -164,7 +164,29 @@ async def get_dashboard_data(
|
||||
return response_payload
|
||||
except Exception as retry_err:
|
||||
logger.error(f"Schema fix and retry failed: {retry_err}")
|
||||
raise HTTPException(status_code=500, detail=f"Database schema error: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(retry_err),
|
||||
"data": {
|
||||
"current_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}},
|
||||
"trends": [],
|
||||
"limits": {"limits": {"monthly_cost": 0}},
|
||||
"alerts": [],
|
||||
"projections": {"projected_monthly_cost": 0, "cost_limit": 0, "projected_usage_percentage": 0},
|
||||
"summary": {"total_api_calls_this_month": 0, "total_cost_this_month": 0, "usage_status": "error", "unread_alerts": 0}
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(f"Error getting dashboard data: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"data": {
|
||||
"current_usage": {"total_calls": 0, "total_cost": 0, "usage_status": "error", "provider_breakdown": {}},
|
||||
"trends": [],
|
||||
"limits": {"limits": {"monthly_cost": 0}},
|
||||
"alerts": [],
|
||||
"projections": {"projected_monthly_cost": 0, "cost_limit": 0, "projected_usage_percentage": 0},
|
||||
"summary": {"total_api_calls_this_month": 0, "total_cost_this_month": 0, "usage_status": "error", "unread_alerts": 0}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +115,15 @@ async def preflight_check(
|
||||
if op['provider'] in [APIProvider.VIDEO, APIProvider.IMAGE_EDIT, APIProvider.STABILITY]:
|
||||
cost = pricing_info.get('cost_per_request', 0.0) or pricing_info.get('cost_per_image', 0.0) or 0.0
|
||||
elif op['provider'] == APIProvider.AUDIO:
|
||||
# Audio pricing is per character (every character is 1 token)
|
||||
cost = (pricing_info.get('cost_per_input_token', 0.0) or 0.0) * (op['tokens_requested'] / 1000.0)
|
||||
model_lower = (model_name or "").lower()
|
||||
if model_lower == "minimax/voice-clone":
|
||||
cost = pricing_info.get('cost_per_request', 0.5) or 0.5
|
||||
elif model_lower == "wavespeed-ai/qwen3-tts/voice-clone":
|
||||
chars = max(0, int(op.get('tokens_requested') or 0))
|
||||
cost = max(0.005, 0.005 * (chars / 100.0))
|
||||
else:
|
||||
# Audio pricing is per character (every character is 1 token)
|
||||
cost = (pricing_info.get('cost_per_input_token', 0.0) or 0.0) * (op['tokens_requested'] / 1000.0)
|
||||
elif op['tokens_requested'] > 0:
|
||||
# Token-based cost estimation (rough estimate)
|
||||
cost = (pricing_info.get('cost_per_input_token', 0.0) or 0.0) * (op['tokens_requested'] / 1000)
|
||||
|
||||
@@ -12,6 +12,7 @@ import sqlite3
|
||||
from services.database import get_db
|
||||
from services.subscription import UsageTrackingService, PricingService
|
||||
from services.subscription.schema_utils import ensure_subscription_plan_columns
|
||||
from services.user_workspace_manager import UserWorkspaceManager
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from models.subscription_models import (
|
||||
SubscriptionPlan, UserSubscription, UsageSummary,
|
||||
@@ -93,7 +94,23 @@ async def get_user_subscription(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user subscription: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"data": {
|
||||
"subscription": None,
|
||||
"plan": {
|
||||
"id": "error_fallback",
|
||||
"name": "Error Fallback",
|
||||
"tier": "free",
|
||||
"price_monthly": 0,
|
||||
"description": "Unable to load subscription details",
|
||||
"is_free": True
|
||||
},
|
||||
"status": "error",
|
||||
"limits": {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status/{user_id}")
|
||||
@@ -255,11 +272,29 @@ async def get_subscription_status(
|
||||
}
|
||||
}
|
||||
except Exception as retry_err:
|
||||
logger.error(f"Schema fix and retry failed: {retry_err}")
|
||||
raise HTTPException(status_code=500, detail=f"Database schema error: {str(e)}")
|
||||
logger.error(f"Schema fix and retry failed: {retry_err}", exc_info=True)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"active": False,
|
||||
"plan": "none",
|
||||
"tier": "none",
|
||||
"can_use_api": False,
|
||||
"reason": f"Database schema error: {str(e)}"
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(f"Error getting subscription status: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error(f"Error getting subscription status: {e}", exc_info=True)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"active": False,
|
||||
"plan": "none",
|
||||
"tier": "none",
|
||||
"can_use_api": False,
|
||||
"reason": f"Failed to check subscription status: {str(e)}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/subscribe/{user_id}")
|
||||
@@ -383,6 +418,18 @@ async def subscribe_to_plan(
|
||||
auto_renew=True
|
||||
)
|
||||
db.add(subscription)
|
||||
|
||||
# Ensure user workspace exists for new subscribers
|
||||
# MOVED: Workspace creation is now handled exclusively in the onboarding flow
|
||||
# to prevent premature creation before plan selection/onboarding.
|
||||
# See onboarding_control_service.py
|
||||
# try:
|
||||
# logger.info(f"Creating workspace for new subscriber {user_id}")
|
||||
# workspace_manager = UserWorkspaceManager(db)
|
||||
# workspace_manager.create_user_workspace(user_id)
|
||||
# except Exception as ws_error:
|
||||
# logger.error(f"Failed to create workspace for new subscriber {user_id}: {ws_error}")
|
||||
# # Don't fail the subscription if workspace creation fails, but log it
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -491,6 +538,15 @@ async def subscribe_to_plan(
|
||||
except Exception as reset_err:
|
||||
logger.error(f" ❌ Failed to reset usage after subscribe: {reset_err}", exc_info=True)
|
||||
|
||||
# Ensure user workspace is created/verified upon subscription
|
||||
try:
|
||||
workspace_manager = UserWorkspaceManager(db)
|
||||
workspace_manager.create_user_workspace(user_id)
|
||||
logger.info(f" ✅ User workspace verified/created for user {user_id}")
|
||||
except Exception as ws_err:
|
||||
# Log but don't fail the subscription response, as workspace can be created later
|
||||
logger.error(f" ⚠️ Failed to create user workspace during subscription: {ws_err}")
|
||||
|
||||
logger.info(f" ✅ Renewal completed: User {user_id} → {plan.name} ({billing_cycle})")
|
||||
logger.info("=" * 80)
|
||||
|
||||
|
||||
197
backend/api/today_workflow.py
Normal file
197
backend/api/today_workflow.py
Normal file
@@ -0,0 +1,197 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Any, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from services.database import get_db
|
||||
from services.today_workflow_service import get_or_create_daily_workflow_plan, update_task_status
|
||||
from models.daily_workflow_models import DailyWorkflowPlan, DailyWorkflowTask
|
||||
import asyncio
|
||||
from services.intelligence.txtai_service import TxtaiIntelligenceService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/today-workflow", tags=["Today Workflow"])
|
||||
|
||||
async def _index_tasks_to_sif(user_id: str, date: str, tasks: list[dict], label: str):
|
||||
svc = TxtaiIntelligenceService(user_id)
|
||||
items = []
|
||||
for t in tasks:
|
||||
task_id = t.get("id")
|
||||
pillar_id = t.get("pillarId")
|
||||
status = t.get("status")
|
||||
title = t.get("title")
|
||||
description = t.get("description")
|
||||
text = f"[{pillar_id}] {title}\n{description}\nstatus={status}"
|
||||
metadata = {
|
||||
"type": "daily_workflow_task",
|
||||
"date": date,
|
||||
"label": label,
|
||||
"pillar_id": pillar_id,
|
||||
"status": status,
|
||||
"implemented": status == "completed",
|
||||
"dismissed": status == "skipped",
|
||||
"task_id": task_id,
|
||||
}
|
||||
items.append((f"{label}_task:{user_id}:{date}:{task_id}", text, metadata))
|
||||
try:
|
||||
await svc.index_content(items)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_today_workflow(
|
||||
date: Optional[str] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = str(current_user.get("id"))
|
||||
plan, created = get_or_create_daily_workflow_plan(db, user_id, date=date)
|
||||
|
||||
tasks = (
|
||||
db.query(DailyWorkflowTask)
|
||||
.filter(DailyWorkflowTask.plan_id == plan.id, DailyWorkflowTask.user_id == user_id)
|
||||
.order_by(DailyWorkflowTask.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
response_tasks = []
|
||||
for t in tasks:
|
||||
response_tasks.append(
|
||||
{
|
||||
"id": str(t.id),
|
||||
"pillarId": t.pillar_id,
|
||||
"title": t.title,
|
||||
"description": t.description,
|
||||
"status": "skipped" if t.status == "dismissed" else t.status,
|
||||
"priority": t.priority,
|
||||
"estimatedTime": t.estimated_time,
|
||||
"dependencies": t.dependencies or [],
|
||||
"actionUrl": t.action_url,
|
||||
"actionType": t.action_type,
|
||||
"metadata": t.metadata_json or {},
|
||||
"enabled": bool(t.enabled),
|
||||
}
|
||||
)
|
||||
|
||||
total = len(response_tasks)
|
||||
completed = len([t for t in response_tasks if t["status"] in ("completed", "skipped")])
|
||||
current_index = 0
|
||||
for i, task in enumerate(response_tasks):
|
||||
if task["status"] not in ("completed", "skipped"):
|
||||
current_index = i
|
||||
break
|
||||
current_index = i
|
||||
|
||||
workflow_status = "not_started"
|
||||
if completed > 0 and completed < total:
|
||||
workflow_status = "in_progress"
|
||||
elif total > 0 and completed == total:
|
||||
workflow_status = "completed"
|
||||
|
||||
total_estimated = int(sum(int(t.get("estimatedTime") or 0) for t in response_tasks))
|
||||
|
||||
if created:
|
||||
asyncio.create_task(_index_tasks_to_sif(user_id, plan.date, response_tasks, label="today"))
|
||||
try:
|
||||
from datetime import date as date_type, timedelta
|
||||
|
||||
y_str = (date_type.fromisoformat(plan.date) - timedelta(days=1)).isoformat()
|
||||
y_plan = (
|
||||
db.query(DailyWorkflowPlan)
|
||||
.filter(DailyWorkflowPlan.user_id == user_id, DailyWorkflowPlan.date == y_str)
|
||||
.first()
|
||||
)
|
||||
if y_plan:
|
||||
y_tasks = (
|
||||
db.query(DailyWorkflowTask)
|
||||
.filter(DailyWorkflowTask.plan_id == y_plan.id, DailyWorkflowTask.user_id == user_id)
|
||||
.order_by(DailyWorkflowTask.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
y_response = []
|
||||
for t in y_tasks:
|
||||
y_response.append(
|
||||
{
|
||||
"id": str(t.id),
|
||||
"pillarId": t.pillar_id,
|
||||
"title": t.title,
|
||||
"description": t.description,
|
||||
"status": "skipped" if t.status == "dismissed" else t.status,
|
||||
}
|
||||
)
|
||||
asyncio.create_task(_index_tasks_to_sif(user_id, y_str, y_response, label="yesterday"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"workflow": {
|
||||
"id": f"daily-{user_id}-{plan.date}",
|
||||
"date": plan.date,
|
||||
"userId": user_id,
|
||||
"tasks": response_tasks,
|
||||
"currentTaskIndex": current_index,
|
||||
"completedTasks": completed,
|
||||
"totalTasks": total,
|
||||
"workflowStatus": workflow_status,
|
||||
"totalEstimatedTime": total_estimated,
|
||||
"actualTimeSpent": 0,
|
||||
},
|
||||
"plan": {
|
||||
"id": plan.id,
|
||||
"date": plan.date,
|
||||
"source": plan.source,
|
||||
"created_at": plan.created_at.isoformat() if plan.created_at else None,
|
||||
"updated_at": plan.updated_at.isoformat() if plan.updated_at else None,
|
||||
},
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"user_id": user_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/status")
|
||||
async def set_task_status(
|
||||
task_id: int,
|
||||
body: Dict[str, Any],
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = str(current_user.get("id"))
|
||||
status = body.get("status")
|
||||
if not status:
|
||||
raise HTTPException(status_code=400, detail="status is required")
|
||||
completion_notes = body.get("completion_notes")
|
||||
|
||||
task = update_task_status(db, user_id, task_id, status=status, completion_notes=completion_notes)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
plan_for_date = db.query(DailyWorkflowPlan).filter(DailyWorkflowPlan.id == task.plan_id).first()
|
||||
plan_date = plan_for_date.date if plan_for_date and plan_for_date.date else ""
|
||||
task_payload = {
|
||||
"id": str(task.id),
|
||||
"pillarId": task.pillar_id,
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"status": "skipped" if task.status == "dismissed" else task.status,
|
||||
}
|
||||
asyncio.create_task(_index_tasks_to_sif(user_id, plan_date, [task_payload], label="today"))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"task": {
|
||||
"id": str(task.id),
|
||||
"pillarId": task.pillar_id,
|
||||
"status": "skipped" if task.status == "dismissed" else task.status,
|
||||
"decided_at": task.decided_at.isoformat() if task.decided_at else None,
|
||||
}
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"user_id": user_id,
|
||||
}
|
||||
@@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/wix", tags=["Wix Integration"])
|
||||
wix_service = WixService()
|
||||
|
||||
# Initialize Wix OAuth service for token storage
|
||||
wix_oauth_service = WixOAuthService(db_path=os.path.abspath("alwrity.db"))
|
||||
wix_oauth_service = WixOAuthService()
|
||||
|
||||
|
||||
class WixAuthRequest(BaseModel):
|
||||
|
||||
@@ -19,8 +19,9 @@ router = APIRouter(tags=["youtube-audio"])
|
||||
logger = get_service_logger("api.youtube.audio")
|
||||
|
||||
# Audio output directory
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
YOUTUBE_AUDIO_DIR = base_dir / "youtube_audio"
|
||||
# api/youtube/handlers/audio.py -> handlers -> youtube -> api -> backend -> root
|
||||
base_dir = Path(__file__).resolve().parents[4]
|
||||
YOUTUBE_AUDIO_DIR = base_dir / "workspace" / "media" / "youtube_audio"
|
||||
YOUTUBE_AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize audio service
|
||||
|
||||
@@ -19,8 +19,10 @@ router = APIRouter(prefix="/avatar", tags=["youtube-avatar"])
|
||||
logger = get_service_logger("api.youtube.avatar")
|
||||
|
||||
# Directories
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
YOUTUBE_AVATARS_DIR = base_dir / "youtube_avatars"
|
||||
# api/youtube/handlers/avatar.py -> handlers -> youtube -> api -> backend -> root
|
||||
base_dir = Path(__file__).parent.parent.parent.parent.parent
|
||||
DATA_MEDIA_DIR = base_dir / "data" / "media"
|
||||
YOUTUBE_AVATARS_DIR = DATA_MEDIA_DIR / "youtube_avatars"
|
||||
YOUTUBE_AVATARS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
|
||||
@@ -23,10 +23,12 @@ router = APIRouter(tags=["youtube-image"])
|
||||
logger = get_service_logger("api.youtube.image")
|
||||
|
||||
# Directories
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
YOUTUBE_IMAGES_DIR = base_dir / "youtube_images"
|
||||
# api/youtube/handlers/images.py -> handlers -> youtube -> api -> backend -> root
|
||||
base_dir = Path(__file__).parent.parent.parent.parent.parent
|
||||
DATA_MEDIA_DIR = base_dir / "data" / "media"
|
||||
YOUTUBE_IMAGES_DIR = DATA_MEDIA_DIR / "youtube_images"
|
||||
YOUTUBE_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
YOUTUBE_AVATARS_DIR = base_dir / "youtube_avatars"
|
||||
YOUTUBE_AVATARS_DIR = DATA_MEDIA_DIR / "youtube_avatars"
|
||||
|
||||
# Thread pool for background image generation
|
||||
_image_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="youtube_image")
|
||||
|
||||
@@ -25,6 +25,7 @@ from models.content_asset_models import AssetType, AssetSource
|
||||
from utils.logger_utils import get_service_logger
|
||||
from utils.asset_tracker import save_asset_to_library
|
||||
from services.story_writer.video_generation_service import StoryVideoGenerationService
|
||||
from services.user_workspace_manager import UserWorkspaceManager
|
||||
from .task_manager import task_manager
|
||||
from .handlers import avatar as avatar_handlers
|
||||
from .handlers import images as image_handlers
|
||||
@@ -34,13 +35,16 @@ router = APIRouter(prefix="/youtube", tags=["youtube"])
|
||||
logger = get_service_logger("api.youtube")
|
||||
|
||||
# Video output and image directories
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
YOUTUBE_VIDEO_DIR = base_dir / "youtube_videos"
|
||||
YOUTUBE_VIDEO_DIR.mkdir(parents=True, exist_ok=True)
|
||||
YOUTUBE_AVATARS_DIR = base_dir / "youtube_avatars"
|
||||
YOUTUBE_AVATARS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
YOUTUBE_IMAGES_DIR = base_dir / "youtube_images"
|
||||
YOUTUBE_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# api/youtube/router.py -> youtube -> api -> backend -> root
|
||||
base_dir = Path(__file__).resolve().parents[3]
|
||||
DATA_MEDIA_DIR = base_dir / "workspace" / "media"
|
||||
YOUTUBE_VIDEO_DIR = DATA_MEDIA_DIR / "youtube_videos"
|
||||
YOUTUBE_AVATARS_DIR = DATA_MEDIA_DIR / "youtube_avatars"
|
||||
YOUTUBE_IMAGES_DIR = DATA_MEDIA_DIR / "youtube_images"
|
||||
|
||||
# Ensure directories exist
|
||||
for directory in [YOUTUBE_VIDEO_DIR, YOUTUBE_AVATARS_DIR, YOUTUBE_IMAGES_DIR]:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Include sub-routers for avatar, images, and audio
|
||||
router.include_router(avatar_handlers.router)
|
||||
@@ -820,6 +824,11 @@ def _execute_video_render_task(
|
||||
)
|
||||
return
|
||||
|
||||
# Create DB session for workspace resolution
|
||||
from services.database import get_db
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
|
||||
try:
|
||||
task_manager.update_task_status(
|
||||
task_id, "processing", progress=5.0, message="Initializing render..."
|
||||
@@ -892,6 +901,7 @@ def _execute_video_render_task(
|
||||
resolution=resolution,
|
||||
generate_audio_enabled=True,
|
||||
voice_id=voice_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
scene_results.append(scene_result)
|
||||
@@ -899,35 +909,30 @@ def _execute_video_render_task(
|
||||
|
||||
# Save to asset library
|
||||
try:
|
||||
from services.database import get_db
|
||||
db = next(get_db())
|
||||
try:
|
||||
save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
asset_type="video",
|
||||
source_module="youtube_creator",
|
||||
filename=scene_result["video_filename"],
|
||||
file_url=scene_result["video_url"],
|
||||
file_path=scene_result["video_path"],
|
||||
file_size=scene_result["file_size"],
|
||||
mime_type="video/mp4",
|
||||
title=f"YouTube Scene {scene_num}: {scene.get('title', 'Untitled')}",
|
||||
description=f"Scene {scene_num} from YouTube video",
|
||||
prompt=scene.get("visual_prompt", ""),
|
||||
tags=["youtube_creator", "video", "scene", f"scene_{scene_num}", resolution],
|
||||
provider="wavespeed",
|
||||
model="alibaba/wan-2.5/text-to-video",
|
||||
cost=scene_result["cost"],
|
||||
asset_metadata={
|
||||
"scene_number": scene_num,
|
||||
"duration": scene_result["duration"],
|
||||
"resolution": resolution,
|
||||
"status": "completed"
|
||||
}
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
asset_type="video",
|
||||
source_module="youtube_creator",
|
||||
filename=scene_result["video_filename"],
|
||||
file_url=scene_result["video_url"],
|
||||
file_path=scene_result["video_path"],
|
||||
file_size=scene_result["file_size"],
|
||||
mime_type="video/mp4",
|
||||
title=f"YouTube Scene {scene_num}: {scene.get('title', 'Untitled')}",
|
||||
description=f"Scene {scene_num} from YouTube video",
|
||||
prompt=scene.get("visual_prompt", ""),
|
||||
tags=["youtube_creator", "video", "scene", f"scene_{scene_num}", resolution],
|
||||
provider="wavespeed",
|
||||
model="alibaba/wan-2.5/text-to-video",
|
||||
cost=scene_result["cost"],
|
||||
asset_metadata={
|
||||
"scene_number": scene_num,
|
||||
"duration": scene_result["duration"],
|
||||
"resolution": resolution,
|
||||
"status": "completed"
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[YouTubeRenderer] Failed to save scene to library: {e}")
|
||||
|
||||
@@ -1070,6 +1075,7 @@ def _execute_video_render_task(
|
||||
resolution=resolution,
|
||||
combine_scenes=True,
|
||||
voice_id=voice_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
final_video_url = combined_result.get("final_video_url")
|
||||
@@ -1132,6 +1138,9 @@ def _execute_video_render_task(
|
||||
error=error_msg,
|
||||
message=f"Video rendering error: {error_msg}",
|
||||
)
|
||||
finally:
|
||||
if 'db' in locals():
|
||||
db.close()
|
||||
|
||||
|
||||
def _execute_scene_video_render_task(
|
||||
@@ -1156,6 +1165,11 @@ def _execute_scene_video_render_task(
|
||||
)
|
||||
return
|
||||
|
||||
# Create DB session for workspace resolution
|
||||
from services.database import get_db
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
|
||||
try:
|
||||
task_manager.update_task_status(
|
||||
task_id, "processing", progress=5.0, message=f"Rendering scene {scene_num}..."
|
||||
@@ -1170,6 +1184,7 @@ def _execute_scene_video_render_task(
|
||||
resolution=resolution,
|
||||
generate_audio_enabled=generate_audio_enabled,
|
||||
voice_id=voice_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
total_cost = scene_result.get("cost", 0.0) or 0.0
|
||||
@@ -1229,6 +1244,9 @@ def _execute_scene_video_render_task(
|
||||
error=error_msg,
|
||||
message=f"Scene {scene_num} rendering error: {error_msg}",
|
||||
)
|
||||
finally:
|
||||
if 'db' in locals():
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/render/combine", response_model=CombineVideosResponse)
|
||||
@@ -1398,19 +1416,50 @@ def _execute_combine_video_task(
|
||||
logger.error(f"[YouTubeRenderer] Task {task_id} not found when combine task started.")
|
||||
return
|
||||
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
youtube_video_dir = base_dir / "youtube_videos"
|
||||
# Create DB session for workspace resolution
|
||||
from services.database import get_db
|
||||
from services.user_workspace_manager import UserWorkspaceManager
|
||||
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
|
||||
try:
|
||||
task_manager.update_task_status(
|
||||
task_id, "processing", progress=5.0, message="Preparing to combine videos..."
|
||||
)
|
||||
|
||||
# Resolve user workspace directory
|
||||
workspace_manager = UserWorkspaceManager(db)
|
||||
workspace_info = workspace_manager.get_user_workspace(user_id)
|
||||
|
||||
if workspace_info and workspace_info.get('workspace_path'):
|
||||
user_video_dir = Path(workspace_info['workspace_path']) / "content" / "videos"
|
||||
if not user_video_dir.exists():
|
||||
user_video_dir.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
# Fallback to default directory
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
user_video_dir = base_dir / "youtube_videos"
|
||||
logger.warning(f"Workspace not found for user {user_id}, using default directory: {user_video_dir}")
|
||||
|
||||
# Fallback directory (legacy global directory) for backward compatibility
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
legacy_video_dir = base_dir / "youtube_videos"
|
||||
|
||||
# Resolve video paths from URLs
|
||||
video_paths: List[Path] = []
|
||||
for url in scene_video_urls:
|
||||
filename = Path(url).name
|
||||
video_path = youtube_video_dir / filename
|
||||
|
||||
# Check user directory first
|
||||
video_path = user_video_dir / filename
|
||||
|
||||
# If not found, check legacy directory
|
||||
if not video_path.exists():
|
||||
legacy_path = legacy_video_dir / filename
|
||||
if legacy_path.exists():
|
||||
video_path = legacy_path
|
||||
|
||||
if not video_path.exists():
|
||||
logger.error(f"[YouTubeRenderer] Video file not found for combine: {video_path}")
|
||||
raise HTTPException(
|
||||
@@ -1426,7 +1475,8 @@ def _execute_combine_video_task(
|
||||
task_id, "processing", progress=25.0, message="Combining scene videos..."
|
||||
)
|
||||
|
||||
video_service = StoryVideoGenerationService(output_dir=str(youtube_video_dir))
|
||||
# Use user video directory for output
|
||||
video_service = StoryVideoGenerationService(output_dir=str(user_video_dir))
|
||||
combined_result = video_service.generate_story_video(
|
||||
scenes=[
|
||||
{"scene_number": idx + 1, "title": f"Scene {idx + 1}"}
|
||||
@@ -1448,34 +1498,30 @@ def _execute_combine_video_task(
|
||||
final_url = combined_result["video_url"]
|
||||
file_size = combined_result.get("file_size", 0)
|
||||
|
||||
# Save to asset library
|
||||
# Save to asset library using existing db session
|
||||
try:
|
||||
db = next(get_db())
|
||||
try:
|
||||
save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
asset_type="video",
|
||||
source_module="youtube_creator",
|
||||
filename=Path(final_path).name,
|
||||
file_url=final_url,
|
||||
file_path=str(final_path),
|
||||
file_size=file_size,
|
||||
mime_type="video/mp4",
|
||||
title=title or "YouTube Video",
|
||||
description="Combined YouTube creator video",
|
||||
tags=["youtube_creator", "video", "combined", resolution],
|
||||
provider="wavespeed",
|
||||
model="alibaba/wan-2.5/text-to-video",
|
||||
cost=0.0,
|
||||
asset_metadata={
|
||||
"resolution": resolution,
|
||||
"status": "completed",
|
||||
"scene_count": len(video_paths),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
asset_type="video",
|
||||
source_module="youtube_creator",
|
||||
filename=Path(final_path).name,
|
||||
file_url=final_url,
|
||||
file_path=str(final_path),
|
||||
file_size=file_size,
|
||||
mime_type="video/mp4",
|
||||
title=title or "YouTube Video",
|
||||
description="Combined YouTube creator video",
|
||||
tags=["youtube_creator", "video", "combined", resolution],
|
||||
provider="wavespeed",
|
||||
model="alibaba/wan-2.5/text-to-video",
|
||||
cost=0.0,
|
||||
asset_metadata={
|
||||
"resolution": resolution,
|
||||
"status": "completed",
|
||||
"scene_count": len(video_paths),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[YouTubeRenderer] Failed to save combined video to asset library: {e}")
|
||||
|
||||
@@ -1516,6 +1562,9 @@ def _execute_combine_video_task(
|
||||
error=error_msg,
|
||||
message=f"Combine error: {error_msg}",
|
||||
)
|
||||
finally:
|
||||
if 'db' in locals():
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/estimate-cost", response_model=CostEstimateResponse)
|
||||
|
||||
@@ -99,6 +99,8 @@ from api.content_planning.strategy_copilot import router as strategy_copilot_rou
|
||||
# Import database service
|
||||
from services.database import init_database, close_database
|
||||
|
||||
# Trigger reload for monitoring fix
|
||||
|
||||
# Import OAuth token monitoring routes
|
||||
from api.oauth_token_monitoring_routes import router as oauth_token_monitoring_router
|
||||
|
||||
@@ -120,7 +122,14 @@ from api.seo_dashboard import (
|
||||
get_gsc_raw_data,
|
||||
get_bing_raw_data,
|
||||
get_competitive_insights,
|
||||
refresh_analytics_data
|
||||
get_deep_competitor_analysis,
|
||||
run_strategic_insights,
|
||||
get_strategic_insights_history,
|
||||
refresh_analytics_data,
|
||||
analyze_urls_ai,
|
||||
AnalyzeURLsRequest,
|
||||
get_analyzed_pages,
|
||||
get_semantic_health # Phase 2B: Semantic health monitoring
|
||||
)
|
||||
|
||||
# Initialize FastAPI app
|
||||
@@ -282,6 +291,21 @@ async def competitive_insights_endpoint(current_user: dict = Depends(get_current
|
||||
"""Get competitive insights from onboarding step 3 data."""
|
||||
return await get_competitive_insights(current_user, site_url)
|
||||
|
||||
@app.get("/api/seo-dashboard/deep-competitor-analysis")
|
||||
async def deep_competitor_analysis_endpoint(current_user: dict = Depends(get_current_user), site_url: str = None):
|
||||
"""Get deep competitor analysis results (auto-scheduled post-onboarding)."""
|
||||
return await get_deep_competitor_analysis(current_user, site_url)
|
||||
|
||||
@app.post("/api/seo-dashboard/strategic-insights/run")
|
||||
async def run_strategic_insights_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
"""Run AI-powered strategic insights analysis manually."""
|
||||
return await run_strategic_insights(current_user)
|
||||
|
||||
@app.get("/api/seo-dashboard/strategic-insights/history")
|
||||
async def get_strategic_insights_history_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
"""Fetch the history of strategic insights for the user."""
|
||||
return await get_strategic_insights_history(current_user)
|
||||
|
||||
@app.post("/api/seo-dashboard/refresh")
|
||||
async def refresh_analytics_data_endpoint(current_user: dict = Depends(get_current_user), site_url: str = None):
|
||||
"""Refresh analytics data by invalidating cache and fetching fresh data."""
|
||||
@@ -292,6 +316,27 @@ async def seo_dashboard_health():
|
||||
"""Health check for SEO dashboard."""
|
||||
return await seo_dashboard_health_check()
|
||||
|
||||
# Phase 2B: Semantic health monitoring endpoint (24-hour polling)
|
||||
@app.get("/api/seo-dashboard/semantic-health")
|
||||
async def semantic_health_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Get real-time semantic health metrics for content and competitors.
|
||||
This endpoint provides Phase 2B semantic intelligence monitoring data.
|
||||
|
||||
Returns semantic health score, status, and recommendations.
|
||||
Data is cached and updated every 24 hours via scheduler.
|
||||
"""
|
||||
return await get_semantic_health(current_user)
|
||||
|
||||
|
||||
@app.get("/api/seo-dashboard/cache-stats")
|
||||
async def semantic_cache_stats_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Get semantic cache performance statistics.
|
||||
Returns hit rate, memory usage, and eviction counts.
|
||||
"""
|
||||
return await get_semantic_cache_stats(current_user)
|
||||
|
||||
# Comprehensive SEO Analysis endpoints
|
||||
@app.post("/api/seo-dashboard/analyze-comprehensive")
|
||||
async def analyze_seo_comprehensive_endpoint(request: SEOAnalysisRequest):
|
||||
@@ -318,6 +363,11 @@ async def batch_analyze_urls_endpoint(urls: list[str]):
|
||||
"""Analyze multiple URLs in batch."""
|
||||
return await batch_analyze_urls(urls)
|
||||
|
||||
@app.post("/api/seo-dashboard/analyze-urls-ai")
|
||||
async def analyze_urls_ai_endpoint(request: AnalyzeURLsRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Run AI-powered SEO analysis on selected URLs."""
|
||||
return await analyze_urls_ai(request, current_user)
|
||||
|
||||
# Include platform analytics router
|
||||
from routers.platform_analytics import router as platform_analytics_router
|
||||
app.include_router(platform_analytics_router)
|
||||
@@ -350,6 +400,14 @@ from api.scheduler_dashboard import router as scheduler_dashboard_router
|
||||
app.include_router(scheduler_dashboard_router)
|
||||
app.include_router(oauth_token_monitoring_router)
|
||||
|
||||
# Autonomous Agents API routes (Phase 3A)
|
||||
from api.agents_api import router as agents_router
|
||||
app.include_router(agents_router)
|
||||
|
||||
# Today workflow routes
|
||||
from api.today_workflow import router as today_workflow_router
|
||||
app.include_router(today_workflow_router)
|
||||
|
||||
# Setup frontend serving using modular utilities
|
||||
frontend_serving.setup_frontend_serving()
|
||||
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
# Asset Tracking Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the production-ready implementation of asset tracking across all ALwrity modules. The unified Content Asset Library automatically tracks all AI-generated content (images, videos, audio, text) for easy management and organization.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Database Models** (`backend/models/content_asset_models.py`)
|
||||
- `ContentAsset`: Main model for tracking assets
|
||||
- `AssetCollection`: Collections/albums for organizing assets
|
||||
- `AssetType`: Enum (text, image, video, audio)
|
||||
- `AssetSource`: Enum (all ALwrity modules)
|
||||
|
||||
2. **Service Layer** (`backend/services/content_asset_service.py`)
|
||||
- CRUD operations for assets
|
||||
- Search, filter, pagination
|
||||
- Usage tracking
|
||||
|
||||
3. **Utility Functions**
|
||||
- `backend/utils/asset_tracker.py`: `save_asset_to_library()` helper
|
||||
- `backend/utils/file_storage.py`: Robust file saving utilities
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### ✅ Completed Integrations
|
||||
|
||||
#### 1. Story Writer (`backend/api/story_writer/router.py`)
|
||||
- **Images**: Tracks all scene images with metadata
|
||||
- **Audio**: Tracks all scene audio files with narration details
|
||||
- **Videos**: Tracks individual scene videos and complete story videos
|
||||
- **Location**: After generation in `/generate-images`, `/generate-audio`, `/generate-video`, `/generate-complete-video`
|
||||
- **Metadata**: Includes prompts, scene numbers, providers, models, costs, status
|
||||
|
||||
#### 2. Image Studio (`backend/api/images.py`)
|
||||
- **Image Generation**: Tracks all generated images
|
||||
- **Image Editing**: Tracks all edited images
|
||||
- **Location**: After generation in `/api/images/generate` and `/api/images/edit`
|
||||
- **Features**:
|
||||
- Robust file saving with validation
|
||||
- Atomic file writes
|
||||
- Proper error handling (non-blocking)
|
||||
- File serving endpoint at `/api/images/image-studio/images/{filename}`
|
||||
|
||||
### 📝 Notes on Other Modules
|
||||
|
||||
#### Main Generation Services
|
||||
- **Text Generation** (`main_text_generation.py`): Returns strings, not files. If text content needs tracking, save to `.txt` or `.md` files first.
|
||||
- **Video Generation** (`main_video_generation.py`): Already integrated via Story Writer
|
||||
- **Audio Generation** (`main_audio_generation.py`): Already integrated via Story Writer
|
||||
|
||||
#### Social Writers
|
||||
- **LinkedIn Writer**: Generates text content (posts, articles). No file generation currently.
|
||||
- **Facebook Writer**: Generates text content (posts, stories, reels). No file generation currently.
|
||||
- **Blog Writer**: Generates blog content. May generate images in future.
|
||||
|
||||
**Note**: If these modules generate files in the future, follow the integration pattern below.
|
||||
|
||||
## Integration Pattern
|
||||
|
||||
### For Image Generation
|
||||
|
||||
```python
|
||||
from utils.asset_tracker import save_asset_to_library
|
||||
from utils.file_storage import save_file_safely, generate_unique_filename
|
||||
from sqlalchemy.orm import Session
|
||||
from pathlib import Path
|
||||
|
||||
# After successful image generation
|
||||
try:
|
||||
base_dir = Path(__file__).parent.parent
|
||||
output_dir = base_dir / "module_images"
|
||||
|
||||
image_filename = generate_unique_filename(
|
||||
prefix="img_prompt",
|
||||
extension=".png",
|
||||
include_uuid=True
|
||||
)
|
||||
|
||||
# Save file safely
|
||||
image_path, save_error = save_file_safely(
|
||||
content=result.image_bytes,
|
||||
directory=output_dir,
|
||||
filename=image_filename,
|
||||
max_file_size=50 * 1024 * 1024 # 50MB
|
||||
)
|
||||
|
||||
if image_path and not save_error:
|
||||
image_url = f"/api/module/images/{image_path.name}"
|
||||
|
||||
# Track in asset library (non-blocking)
|
||||
try:
|
||||
asset_id = save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
asset_type="image",
|
||||
source_module="module_name",
|
||||
filename=image_path.name,
|
||||
file_url=image_url,
|
||||
file_path=str(image_path),
|
||||
file_size=len(result.image_bytes),
|
||||
mime_type="image/png",
|
||||
title="Image Title",
|
||||
description="Image description",
|
||||
prompt=prompt,
|
||||
tags=["tag1", "tag2"],
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
metadata={"status": "completed"}
|
||||
)
|
||||
logger.info(f"✅ Asset saved: ID={asset_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Asset tracking failed: {e}", exc_info=True)
|
||||
# Don't fail the request
|
||||
except Exception as e:
|
||||
logger.error(f"File save failed: {e}", exc_info=True)
|
||||
# Continue - base64 is still available
|
||||
```
|
||||
|
||||
### For Video Generation
|
||||
|
||||
```python
|
||||
# After successful video generation
|
||||
try:
|
||||
asset_id = save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
asset_type="video",
|
||||
source_module="module_name",
|
||||
filename=video_filename,
|
||||
file_url=video_url,
|
||||
file_path=str(video_path),
|
||||
file_size=file_size,
|
||||
mime_type="video/mp4",
|
||||
title="Video Title",
|
||||
description="Video description",
|
||||
prompt=prompt,
|
||||
tags=["video", "tag"],
|
||||
provider=provider,
|
||||
model=model,
|
||||
cost=cost,
|
||||
metadata={"duration": duration, "status": "completed"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Asset tracking failed: {e}", exc_info=True)
|
||||
```
|
||||
|
||||
### For Audio Generation
|
||||
|
||||
```python
|
||||
# After successful audio generation
|
||||
try:
|
||||
asset_id = save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
asset_type="audio",
|
||||
source_module="module_name",
|
||||
filename=audio_filename,
|
||||
file_url=audio_url,
|
||||
file_path=str(audio_path),
|
||||
file_size=file_size,
|
||||
mime_type="audio/mpeg",
|
||||
title="Audio Title",
|
||||
description="Audio description",
|
||||
prompt=text,
|
||||
tags=["audio", "tag"],
|
||||
provider=provider,
|
||||
model=model,
|
||||
cost=cost,
|
||||
metadata={"status": "completed"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Asset tracking failed: {e}", exc_info=True)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Error Handling
|
||||
- **Always non-blocking**: Asset tracking failures should never break the main request
|
||||
- **Log errors**: Use `logger.error()` with `exc_info=True` for debugging
|
||||
- **Graceful degradation**: Continue with base64/file response even if tracking fails
|
||||
|
||||
### 2. File Management
|
||||
- **Use `save_file_safely()`**: Handles validation, atomic writes, directory creation
|
||||
- **Sanitize filenames**: Use `sanitize_filename()` to prevent path traversal
|
||||
- **Unique filenames**: Use `generate_unique_filename()` with UUIDs
|
||||
- **File size limits**: Enforce reasonable limits (50MB for images, 100MB for videos)
|
||||
|
||||
### 3. Database Sessions
|
||||
- **Pass session explicitly**: Use `db: Session = Depends(get_db)` in endpoints
|
||||
- **Handle session lifecycle**: Let FastAPI manage session cleanup
|
||||
- **Background tasks**: Get new session in background tasks
|
||||
|
||||
### 4. Metadata
|
||||
- **Rich metadata**: Include provider, model, dimensions, cost, status
|
||||
- **Searchable tags**: Use consistent tag naming (e.g., "image_studio", "generated")
|
||||
- **Status tracking**: Always include `"status": "completed"` in metadata
|
||||
|
||||
### 5. File URLs
|
||||
- **Consistent patterns**: Use `/api/{module}/images/{filename}` format
|
||||
- **Serving endpoints**: Create corresponding GET endpoints to serve files
|
||||
- **Authentication**: Protect file serving endpoints with `get_current_user`
|
||||
|
||||
## File Storage Utilities
|
||||
|
||||
### `save_file_safely()`
|
||||
- Validates file size
|
||||
- Creates directories automatically
|
||||
- Atomic writes (temp file + rename)
|
||||
- Returns `(file_path, error_message)` tuple
|
||||
|
||||
### `sanitize_filename()`
|
||||
- Removes dangerous characters
|
||||
- Prevents path traversal
|
||||
- Limits filename length
|
||||
- Handles empty filenames
|
||||
|
||||
### `generate_unique_filename()`
|
||||
- Creates unique filenames with UUIDs
|
||||
- Sanitizes prefix
|
||||
- Handles extensions properly
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Images are saved to disk correctly
|
||||
- [ ] Files are accessible via serving endpoints
|
||||
- [ ] Asset tracking works (check database)
|
||||
- [ ] Errors don't break main requests
|
||||
- [ ] File size limits are enforced
|
||||
- [ ] Filenames are sanitized properly
|
||||
- [ ] Metadata is complete and accurate
|
||||
- [ ] Asset Library UI displays assets correctly
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Text Content Tracking**: Save text content as files when needed
|
||||
2. **Batch Operations**: Track multiple assets in single transaction
|
||||
3. **File Cleanup**: Automatic cleanup of orphaned files
|
||||
4. **Storage Backends**: Support S3, GCS for production
|
||||
5. **Thumbnail Generation**: Auto-generate thumbnails for videos/images
|
||||
6. **Compression**: Compress large files before storage
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Assets not appearing in library
|
||||
1. Check database: `SELECT * FROM content_assets WHERE user_id = '...'`
|
||||
2. Check logs for asset tracking errors
|
||||
3. Verify `save_asset_to_library()` returns asset ID
|
||||
4. Check file URLs are correct
|
||||
|
||||
### File serving fails
|
||||
1. Verify file exists on disk
|
||||
2. Check serving endpoint is registered
|
||||
3. Verify authentication is working
|
||||
4. Check file permissions
|
||||
|
||||
### Performance issues
|
||||
1. Use background tasks for heavy operations
|
||||
2. Batch database operations
|
||||
3. Consider async file I/O for large files
|
||||
4. Monitor database query performance
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# Text Asset Tracking Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
Text content tracking has been successfully implemented across LinkedIn Writer and Facebook Writer endpoints. All generated text content is automatically saved as files and tracked in the unified Content Asset Library.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### ✅ Completed Integrations
|
||||
|
||||
#### 1. LinkedIn Writer (`backend/routers/linkedin.py`)
|
||||
- **Post Generation**: Tracks LinkedIn posts with content, hashtags, and CTAs
|
||||
- **Article Generation**: Tracks LinkedIn articles with full content, sections, and SEO metadata
|
||||
- **Carousel Generation**: Tracks LinkedIn carousels with all slides
|
||||
- **Video Script Generation**: Tracks LinkedIn video scripts with hooks, scenes, captions
|
||||
- **Comment Response Generation**: Tracks LinkedIn comment responses
|
||||
|
||||
**File Format**: Markdown (`.md`) for articles, carousels, video scripts, comment responses; Text (`.txt`) for posts
|
||||
|
||||
**Storage Location**: `backend/linkedinwriter_text/{subdirectory}/`
|
||||
- `posts/` - LinkedIn posts
|
||||
- `articles/` - LinkedIn articles
|
||||
- `carousels/` - LinkedIn carousels
|
||||
- `video_scripts/` - LinkedIn video scripts
|
||||
- `comment_responses/` - LinkedIn comment responses
|
||||
|
||||
#### 2. Facebook Writer (`backend/api/facebook_writer/routers/facebook_router.py`)
|
||||
- **Post Generation**: Tracks Facebook posts with content and analytics
|
||||
- **Story Generation**: Tracks Facebook stories
|
||||
|
||||
**File Format**: Text (`.txt`)
|
||||
|
||||
**Storage Location**: `backend/facebookwriter_text/{subdirectory}/`
|
||||
- `posts/` - Facebook posts
|
||||
- `stories/` - Facebook stories
|
||||
|
||||
### 📝 Pending Integrations
|
||||
|
||||
#### Facebook Writer (Additional Endpoints)
|
||||
- Reel Generation
|
||||
- Carousel Generation
|
||||
- Event Generation
|
||||
- Group Post Generation
|
||||
- Page About Generation
|
||||
- Ad Copy Generation
|
||||
- Hashtag Generation
|
||||
|
||||
#### Blog Writer (`backend/api/blog_writer/router.py`)
|
||||
- Blog content generation endpoints
|
||||
- Medium blog generation
|
||||
- Blog section generation
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Text Asset Tracker** (`backend/utils/text_asset_tracker.py`)
|
||||
- `save_and_track_text_content()`: Main function for saving and tracking text
|
||||
- Handles file saving, URL generation, and asset library tracking
|
||||
- Non-blocking error handling
|
||||
|
||||
2. **File Storage Utilities** (`backend/utils/file_storage.py`)
|
||||
- `save_text_file_safely()`: Safely saves text files with validation
|
||||
- `sanitize_filename()`: Prevents path traversal
|
||||
- `generate_unique_filename()`: Creates unique filenames
|
||||
|
||||
3. **Asset Tracker** (`backend/utils/asset_tracker.py`)
|
||||
- `save_asset_to_library()`: Saves asset metadata to database
|
||||
|
||||
## Integration Pattern
|
||||
|
||||
### Basic Integration
|
||||
|
||||
```python
|
||||
from utils.text_asset_tracker import save_and_track_text_content
|
||||
from sqlalchemy.orm import Session
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
@router.post("/generate-content")
|
||||
async def generate_content(
|
||||
request: ContentRequest,
|
||||
current_user: Optional[Dict[str, Any]] = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Generate content
|
||||
response = await service.generate(request)
|
||||
|
||||
# Save and track text content (non-blocking)
|
||||
if response.content:
|
||||
try:
|
||||
user_id = str(current_user.get('id', '') or current_user.get('sub', ''))
|
||||
|
||||
if user_id:
|
||||
save_and_track_text_content(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
content=response.content,
|
||||
source_module="module_name",
|
||||
title=f"Content Title: {request.topic[:60]}",
|
||||
description=f"Content description",
|
||||
prompt=f"Topic: {request.topic}",
|
||||
tags=["tag1", "tag2"],
|
||||
metadata={"key": "value"},
|
||||
subdirectory="content_type"
|
||||
)
|
||||
except Exception as track_error:
|
||||
logger.warning(f"Failed to track text asset: {track_error}")
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
## File Serving
|
||||
|
||||
Text files are saved with URLs like `/api/text-assets/{module}/{subdirectory}/{filename}`. A serving endpoint should be created in `backend/app.py`:
|
||||
|
||||
```python
|
||||
@router.get("/api/text-assets/{file_path:path}")
|
||||
async def serve_text_asset(
|
||||
file_path: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Serve text assets with authentication."""
|
||||
# Implementation needed
|
||||
pass
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Non-blocking**: Text tracking failures should never break the main request
|
||||
2. **Error Handling**: Use try/except around tracking calls
|
||||
3. **User ID Extraction**: Support both `current_user` dependency and header-based extraction
|
||||
4. **Content Formatting**: Combine related content (e.g., post + hashtags + CTA)
|
||||
5. **Metadata**: Include rich metadata for search and filtering
|
||||
6. **File Organization**: Use subdirectories to organize by content type
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Add text tracking to remaining Facebook Writer endpoints
|
||||
2. Add text tracking to Blog Writer endpoints
|
||||
3. Create text asset serving endpoint
|
||||
4. Add text preview in Asset Library UI
|
||||
5. Support text file downloads
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 193 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 245 KiB |
460
backend/main.py
Normal file
460
backend/main.py
Normal file
@@ -0,0 +1,460 @@
|
||||
# Ensure typing constructs and models are available globally for FastAPI type annotation evaluation
|
||||
import typing
|
||||
import builtins
|
||||
|
||||
# Make common typing constructs available globally
|
||||
builtins.Optional = typing.Optional
|
||||
builtins.List = typing.List
|
||||
builtins.Dict = typing.Dict
|
||||
builtins.Any = typing.Any
|
||||
builtins.Union = typing.Union
|
||||
|
||||
# Import onboarding models VERY early to ensure they're available before any services
|
||||
from models.onboarding import APIKey, WebsiteAnalysis, ResearchPreferences, PersonaData, CompetitorAnalysis
|
||||
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends, Request, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Any, Optional
|
||||
import os
|
||||
from loguru import logger
|
||||
from dotenv import load_dotenv
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
# Import OnboardingSession right after basic imports to ensure it's available
|
||||
from models.onboarding import OnboardingSession
|
||||
|
||||
from services.subscription import monitoring_middleware
|
||||
|
||||
# Import remaining onboarding models
|
||||
from models import APIKey, WebsiteAnalysis, ResearchPreferences, PersonaData, CompetitorAnalysis
|
||||
|
||||
# Import modular utilities
|
||||
from alwrity_utils import HealthChecker, RateLimiter, FrontendServing, RouterManager
|
||||
from alwrity_utils import OnboardingManager
|
||||
|
||||
# Load environment variables
|
||||
# Try multiple locations for .env file
|
||||
from pathlib import Path
|
||||
backend_dir = Path(__file__).parent
|
||||
project_root = backend_dir.parent
|
||||
|
||||
# Load from backend/.env first (higher priority), then root .env
|
||||
load_dotenv(backend_dir / '.env') # backend/.env
|
||||
load_dotenv(project_root / '.env') # root .env (fallback)
|
||||
load_dotenv() # CWD .env (fallback)
|
||||
|
||||
# Set up clean logging for end users
|
||||
from logging_config import setup_clean_logging
|
||||
setup_clean_logging()
|
||||
|
||||
# Import middleware
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
# Import component logic endpoints (needs OnboardingSession, so import after models)
|
||||
from api.component_logic import router as component_logic_router
|
||||
|
||||
# Import subscription API endpoints
|
||||
from api.subscription import router as subscription_router
|
||||
|
||||
# Import Step 3 onboarding routes
|
||||
from api.onboarding_utils.step3_routes import router as step3_routes
|
||||
|
||||
# Import SEO tools router
|
||||
from routers.seo_tools import router as seo_tools_router
|
||||
# Import Facebook Writer endpoints
|
||||
from api.facebook_writer.routers import facebook_router
|
||||
# Import LinkedIn content generation router
|
||||
from routers.linkedin import router as linkedin_router
|
||||
# Import LinkedIn image generation router
|
||||
from api.linkedin_image_generation import router as linkedin_image_router
|
||||
from api.brainstorm import router as brainstorm_router
|
||||
from api.images import router as images_router
|
||||
from routers.image_studio import router as image_studio_router
|
||||
from routers.product_marketing import router as product_marketing_router
|
||||
from routers.campaign_creator import router as campaign_creator_router
|
||||
|
||||
# Import hallucination detector router
|
||||
from api.hallucination_detector import router as hallucination_detector_router
|
||||
from api.writing_assistant import router as writing_assistant_router
|
||||
|
||||
# Import research configuration router
|
||||
from api.research_config import router as research_config_router
|
||||
|
||||
# Import user data endpoints
|
||||
# Import content planning endpoints
|
||||
from api.content_planning.api.router import router as content_planning_router
|
||||
from api.user_data import router as user_data_router
|
||||
|
||||
# Import user environment endpoints
|
||||
from api.user_environment import router as user_environment_router
|
||||
|
||||
# Import strategy copilot endpoints
|
||||
from api.content_planning.strategy_copilot import router as strategy_copilot_router
|
||||
|
||||
# Import database service
|
||||
from services.database import init_database, close_database
|
||||
|
||||
# Trigger reload for monitoring fix
|
||||
|
||||
# Import OAuth token monitoring routes
|
||||
from api.oauth_token_monitoring_routes import router as oauth_token_monitoring_router
|
||||
|
||||
# Import SEO Dashboard endpoints
|
||||
from api.seo_dashboard import (
|
||||
get_seo_dashboard_data,
|
||||
get_seo_health_score,
|
||||
get_seo_metrics,
|
||||
get_platform_status,
|
||||
get_ai_insights,
|
||||
seo_dashboard_health_check,
|
||||
analyze_seo_comprehensive,
|
||||
analyze_seo_full,
|
||||
get_seo_metrics_detailed,
|
||||
get_analysis_summary,
|
||||
batch_analyze_urls,
|
||||
SEOAnalysisRequest,
|
||||
get_seo_dashboard_overview,
|
||||
get_gsc_raw_data,
|
||||
get_bing_raw_data,
|
||||
get_competitive_insights,
|
||||
get_deep_competitor_analysis,
|
||||
run_strategic_insights,
|
||||
get_strategic_insights_history,
|
||||
refresh_analytics_data,
|
||||
analyze_urls_ai,
|
||||
AnalyzeURLsRequest,
|
||||
get_analyzed_pages,
|
||||
get_semantic_health # Phase 2B: Semantic health monitoring
|
||||
)
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="ALwrity Backend API",
|
||||
description="Backend API for ALwrity - AI-powered content creation platform",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
# Build allowed origins list with env overrides to support dynamic tunnels (e.g., ngrok)
|
||||
default_allowed_origins = [
|
||||
"http://localhost:3000", # React dev server
|
||||
"http://localhost:8000", # Backend dev server
|
||||
"http://localhost:3001", # Alternative React port
|
||||
"https://alwrity-ai.vercel.app", # Vercel frontend
|
||||
]
|
||||
|
||||
# Optional dynamic origins from environment (comma-separated)
|
||||
env_origins = os.getenv("ALWRITY_ALLOWED_ORIGINS", "").split(",") if os.getenv("ALWRITY_ALLOWED_ORIGINS") else []
|
||||
env_origins = [o.strip() for o in env_origins if o.strip()]
|
||||
|
||||
# Convenience: NGROK_URL env var (single origin)
|
||||
ngrok_origin = os.getenv("NGROK_URL")
|
||||
if ngrok_origin:
|
||||
env_origins.append(ngrok_origin.strip())
|
||||
|
||||
allowed_origins = list(dict.fromkeys(default_allowed_origins + env_origins)) # de-duplicate, keep order
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Initialize modular utilities
|
||||
health_checker = HealthChecker()
|
||||
rate_limiter = RateLimiter(window_seconds=60, max_requests=200)
|
||||
frontend_serving = FrontendServing(app)
|
||||
router_manager = RouterManager(app)
|
||||
|
||||
onboarding_manager = OnboardingManager(app)
|
||||
|
||||
# Middleware Order (FastAPI executes in REVERSE order of registration - LIFO):
|
||||
# Registration order: 1. Monitoring 2. Rate Limit 3. API Key Injection
|
||||
# Execution order: 1. API Key Injection (sets user_id) 2. Rate Limit 3. Monitoring (uses user_id)
|
||||
|
||||
# 1. FIRST REGISTERED (runs LAST) - Monitoring middleware
|
||||
app.middleware("http")(monitoring_middleware)
|
||||
|
||||
# 2. SECOND REGISTERED (runs SECOND) - Rate limiting
|
||||
@app.middleware("http")
|
||||
async def rate_limit_middleware(request: Request, call_next):
|
||||
"""Rate limiting middleware using modular utilities."""
|
||||
return await rate_limiter.rate_limit_middleware(request, call_next)
|
||||
|
||||
# 3. LAST REGISTERED (runs FIRST) - API key injection
|
||||
from middleware.api_key_injection_middleware import api_key_injection_middleware
|
||||
app.middleware("http")(api_key_injection_middleware)
|
||||
|
||||
# Health check endpoints using modular utilities
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
return health_checker.basic_health_check()
|
||||
|
||||
@app.get("/health/database")
|
||||
async def database_health():
|
||||
"""Database health check endpoint."""
|
||||
return health_checker.database_health_check()
|
||||
|
||||
@app.get("/health/comprehensive")
|
||||
async def comprehensive_health():
|
||||
"""Comprehensive health check endpoint."""
|
||||
return health_checker.comprehensive_health_check()
|
||||
|
||||
# Rate limiting management endpoints
|
||||
@app.get("/api/rate-limit/status")
|
||||
async def rate_limit_status(request: Request):
|
||||
"""Get current rate limit status for the requesting client."""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
return rate_limiter.get_rate_limit_status(client_ip)
|
||||
|
||||
@app.post("/api/rate-limit/reset")
|
||||
async def reset_rate_limit(request: Request, client_ip: Optional[str] = None):
|
||||
"""Reset rate limit for a specific client or all clients."""
|
||||
if client_ip is None:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
return rate_limiter.reset_rate_limit(client_ip)
|
||||
|
||||
# Frontend serving management endpoints
|
||||
@app.get("/api/frontend/status")
|
||||
async def frontend_status():
|
||||
"""Get frontend serving status."""
|
||||
return frontend_serving.get_frontend_status()
|
||||
|
||||
# Router management endpoints
|
||||
@app.get("/api/routers/status")
|
||||
async def router_status():
|
||||
"""Get router inclusion status."""
|
||||
return router_manager.get_router_status()
|
||||
|
||||
# Onboarding management endpoints
|
||||
@app.get("/api/onboarding/status")
|
||||
async def onboarding_status():
|
||||
"""Get onboarding manager status."""
|
||||
return onboarding_manager.get_onboarding_status()
|
||||
|
||||
# Include routers using modular utilities
|
||||
router_manager.include_core_routers()
|
||||
router_manager.include_optional_routers()
|
||||
|
||||
# SEO Dashboard endpoints
|
||||
@app.get("/api/seo-dashboard/data")
|
||||
async def seo_dashboard_data():
|
||||
"""Get complete SEO dashboard data."""
|
||||
return await get_seo_dashboard_data()
|
||||
|
||||
@app.get("/api/seo-dashboard/health-score")
|
||||
async def seo_health_score():
|
||||
"""Get SEO health score."""
|
||||
return await get_seo_health_score()
|
||||
|
||||
@app.get("/api/seo-dashboard/metrics")
|
||||
async def seo_metrics():
|
||||
"""Get SEO metrics."""
|
||||
return await get_seo_metrics()
|
||||
|
||||
@app.get("/api/seo-dashboard/platforms")
|
||||
async def seo_platforms(current_user: dict = Depends(get_current_user)):
|
||||
"""Get platform status."""
|
||||
return await get_platform_status(current_user)
|
||||
|
||||
@app.get("/api/seo-dashboard/insights")
|
||||
async def seo_insights():
|
||||
"""Get AI insights."""
|
||||
return await get_ai_insights()
|
||||
|
||||
# New SEO Dashboard endpoints with real data
|
||||
@app.get("/api/seo-dashboard/overview")
|
||||
async def seo_dashboard_overview_endpoint(current_user: dict = Depends(get_current_user), site_url: str = None):
|
||||
"""Get comprehensive SEO dashboard overview with real GSC/Bing data."""
|
||||
return await get_seo_dashboard_overview(current_user, site_url)
|
||||
|
||||
@app.get("/api/seo-dashboard/gsc/raw")
|
||||
async def gsc_raw_data_endpoint(current_user: dict = Depends(get_current_user), site_url: str = None):
|
||||
"""Get raw GSC data for the specified site."""
|
||||
return await get_gsc_raw_data(current_user, site_url)
|
||||
|
||||
@app.get("/api/seo-dashboard/bing/raw")
|
||||
async def bing_raw_data_endpoint(current_user: dict = Depends(get_current_user), site_url: str = None):
|
||||
"""Get raw Bing data for the specified site."""
|
||||
return await get_bing_raw_data(current_user, site_url)
|
||||
|
||||
@app.get("/api/seo-dashboard/competitive-insights")
|
||||
async def competitive_insights_endpoint(current_user: dict = Depends(get_current_user), site_url: str = None):
|
||||
"""Get competitive insights from onboarding step 3 data."""
|
||||
return await get_competitive_insights(current_user, site_url)
|
||||
|
||||
@app.get("/api/seo-dashboard/deep-competitor-analysis")
|
||||
async def deep_competitor_analysis_endpoint(current_user: dict = Depends(get_current_user), site_url: str = None):
|
||||
"""Get deep competitor analysis results (auto-scheduled post-onboarding)."""
|
||||
return await get_deep_competitor_analysis(current_user, site_url)
|
||||
|
||||
@app.post("/api/seo-dashboard/strategic-insights/run")
|
||||
async def run_strategic_insights_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
"""Run AI-powered strategic insights analysis manually."""
|
||||
return await run_strategic_insights(current_user)
|
||||
|
||||
@app.get("/api/seo-dashboard/strategic-insights/history")
|
||||
async def get_strategic_insights_history_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
"""Fetch the history of strategic insights for the user."""
|
||||
return await get_strategic_insights_history(current_user)
|
||||
|
||||
@app.post("/api/seo-dashboard/refresh")
|
||||
async def refresh_analytics_data_endpoint(current_user: dict = Depends(get_current_user), site_url: str = None):
|
||||
"""Refresh analytics data by invalidating cache and fetching fresh data."""
|
||||
return await refresh_analytics_data(current_user, site_url)
|
||||
|
||||
@app.get("/api/seo-dashboard/health")
|
||||
async def seo_dashboard_health():
|
||||
"""Health check for SEO dashboard."""
|
||||
return await seo_dashboard_health_check()
|
||||
|
||||
# Phase 2B: Semantic health monitoring endpoint (24-hour polling)
|
||||
@app.get("/api/seo-dashboard/semantic-health")
|
||||
async def semantic_health_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Get real-time semantic health metrics for content and competitors.
|
||||
This endpoint provides Phase 2B semantic intelligence monitoring data.
|
||||
|
||||
Returns semantic health score, status, and recommendations.
|
||||
Data is cached and updated every 24 hours via scheduler.
|
||||
"""
|
||||
return await get_semantic_health(current_user)
|
||||
|
||||
|
||||
@app.get("/api/seo-dashboard/cache-stats")
|
||||
async def semantic_cache_stats_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Get semantic cache performance statistics.
|
||||
Returns hit rate, memory usage, and eviction counts.
|
||||
"""
|
||||
return await get_semantic_cache_stats(current_user)
|
||||
|
||||
# Comprehensive SEO Analysis endpoints
|
||||
@app.post("/api/seo-dashboard/analyze-comprehensive")
|
||||
async def analyze_seo_comprehensive_endpoint(request: SEOAnalysisRequest):
|
||||
"""Analyze a URL for comprehensive SEO performance."""
|
||||
return await analyze_seo_comprehensive(request)
|
||||
|
||||
@app.post("/api/seo-dashboard/analyze-full")
|
||||
async def analyze_seo_full_endpoint(request: SEOAnalysisRequest):
|
||||
"""Analyze a URL for comprehensive SEO performance."""
|
||||
return await analyze_seo_full(request)
|
||||
|
||||
@app.get("/api/seo-dashboard/metrics-detailed")
|
||||
async def seo_metrics_detailed(url: str):
|
||||
"""Get detailed SEO metrics for a URL."""
|
||||
return await get_seo_metrics_detailed(url)
|
||||
|
||||
@app.get("/api/seo-dashboard/analysis-summary")
|
||||
async def seo_analysis_summary(url: str):
|
||||
"""Get a quick summary of SEO analysis for a URL."""
|
||||
return await get_analysis_summary(url)
|
||||
|
||||
@app.post("/api/seo-dashboard/batch-analyze")
|
||||
async def batch_analyze_urls_endpoint(urls: list[str]):
|
||||
"""Analyze multiple URLs in batch."""
|
||||
return await batch_analyze_urls(urls)
|
||||
|
||||
@app.post("/api/seo-dashboard/analyze-urls-ai")
|
||||
async def analyze_urls_ai_endpoint(request: AnalyzeURLsRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Run AI-powered SEO analysis on selected URLs."""
|
||||
return await analyze_urls_ai(request, current_user)
|
||||
|
||||
# Include platform analytics router
|
||||
from routers.platform_analytics import router as platform_analytics_router
|
||||
app.include_router(platform_analytics_router)
|
||||
app.include_router(images_router)
|
||||
app.include_router(image_studio_router)
|
||||
app.include_router(product_marketing_router)
|
||||
app.include_router(campaign_creator_router)
|
||||
|
||||
# Include content assets router
|
||||
from api.content_assets.router import router as content_assets_router
|
||||
app.include_router(content_assets_router)
|
||||
|
||||
# Include Podcast Maker router
|
||||
from api.podcast.router import router as podcast_router
|
||||
app.include_router(podcast_router)
|
||||
|
||||
# Include YouTube Creator Studio router
|
||||
from api.youtube.router import router as youtube_router
|
||||
app.include_router(youtube_router, prefix="/api")
|
||||
|
||||
# Include research configuration router
|
||||
app.include_router(research_config_router, prefix="/api/research", tags=["research"])
|
||||
|
||||
# Include Research Engine router (standalone AI research module)
|
||||
from api.research.router import router as research_engine_router
|
||||
app.include_router(research_engine_router, tags=["Research Engine"])
|
||||
|
||||
# Scheduler dashboard routes
|
||||
from api.scheduler_dashboard import router as scheduler_router
|
||||
app.include_router(scheduler_router)
|
||||
app.include_router(oauth_token_monitoring_router)
|
||||
|
||||
# Include scheduler monitoring API
|
||||
# from api.scheduler_monitoring import router as scheduler_monitoring_router
|
||||
# app.include_router(scheduler_monitoring_router)
|
||||
|
||||
# Autonomous Agents API routes (Phase 3A)
|
||||
from api.agents_api import router as agents_router
|
||||
app.include_router(agents_router)
|
||||
|
||||
# Today workflow routes
|
||||
from api.today_workflow import router as today_workflow_router
|
||||
app.include_router(today_workflow_router)
|
||||
|
||||
# Setup frontend serving using modular utilities
|
||||
frontend_serving.setup_frontend_serving()
|
||||
|
||||
# Serve React frontend (for production)
|
||||
@app.get("/")
|
||||
async def serve_frontend():
|
||||
"""Serve the React frontend."""
|
||||
return frontend_serving.serve_frontend()
|
||||
|
||||
# Startup event
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize services on startup."""
|
||||
try:
|
||||
# Initialize database
|
||||
init_database()
|
||||
|
||||
# Start task scheduler
|
||||
from services.scheduler import get_scheduler
|
||||
await get_scheduler().start()
|
||||
|
||||
# Check Wix API key configuration
|
||||
wix_api_key = os.getenv('WIX_API_KEY')
|
||||
if wix_api_key:
|
||||
logger.warning(f"✅ WIX_API_KEY loaded ({len(wix_api_key)} chars, starts with '{wix_api_key[:10]}...')")
|
||||
else:
|
||||
logger.warning("âš ï¸ WIX_API_KEY not found in environment - Wix publishing may fail")
|
||||
|
||||
logger.info("ALwrity backend started successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during startup: {e}")
|
||||
|
||||
# Shutdown event
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Cleanup on shutdown."""
|
||||
try:
|
||||
# Stop task scheduler
|
||||
from services.scheduler import get_scheduler
|
||||
await get_scheduler().stop()
|
||||
|
||||
# Close database connections
|
||||
close_database()
|
||||
logger.info("ALwrity backend shutdown successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during shutdown: {e}")
|
||||
@@ -16,8 +16,10 @@ from loguru import logger
|
||||
import os
|
||||
import time
|
||||
|
||||
# Logging configuration
|
||||
LOG_BASE_DIR = "logs"
|
||||
# Logging configuration - Store in root workspace to avoid uvicorn reloads
|
||||
# backend/middleware/logging_middleware.py -> middleware -> backend -> root
|
||||
ROOT_DIR = Path(__file__).parent.parent.parent
|
||||
LOG_BASE_DIR = ROOT_DIR / "workspace" / "logs"
|
||||
os.makedirs(LOG_BASE_DIR, exist_ok=True)
|
||||
|
||||
# Ensure subdirectories exist
|
||||
|
||||
100
backend/models/advertools_monitoring_models.py
Normal file
100
backend/models/advertools_monitoring_models.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Advertools Monitoring Models
|
||||
Database models for tracking Advertools-based SEO intelligence tasks.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, JSON, Index, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
# Import the same Base from enhanced_strategy_models
|
||||
from models.enhanced_strategy_models import Base
|
||||
|
||||
|
||||
class AdvertoolsTask(Base):
|
||||
"""
|
||||
Model for storing Advertools intelligence tasks.
|
||||
Tracks weekly content audits and site health monitoring.
|
||||
"""
|
||||
__tablename__ = "advertools_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# User and URL Identification
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
website_url = Column(String(500), nullable=False, index=True)
|
||||
|
||||
# Task Status
|
||||
status = Column(String(50), default='active', index=True) # 'active', 'failed', 'paused'
|
||||
|
||||
# Execution Tracking
|
||||
last_executed = Column(DateTime, nullable=True)
|
||||
last_success = Column(DateTime, nullable=True)
|
||||
last_failure = Column(DateTime, nullable=True)
|
||||
failure_reason = Column(Text, nullable=True)
|
||||
|
||||
# Failure Pattern Tracking
|
||||
consecutive_failures = Column(Integer, default=0)
|
||||
failure_pattern = Column(JSON, nullable=True)
|
||||
|
||||
# Scheduling
|
||||
next_execution = Column(DateTime, nullable=True, index=True)
|
||||
frequency_days = Column(Integer, default=7) # Weekly by default
|
||||
|
||||
# Task Type & Data
|
||||
payload = Column(JSON, nullable=True) # {"type": "content_audit", "website_url": "..."}
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Execution Logs Relationship
|
||||
execution_logs = relationship(
|
||||
"AdvertoolsExecutionLog",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_advertools_tasks_user_site', 'user_id', 'website_url'),
|
||||
Index('idx_advertools_tasks_next_execution', 'next_execution'),
|
||||
Index('idx_advertools_tasks_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdvertoolsTask(id={self.id}, user_id={self.user_id}, url={self.website_url}, status={self.status})>"
|
||||
|
||||
|
||||
class AdvertoolsExecutionLog(Base):
|
||||
"""
|
||||
Model for storing Advertools execution logs.
|
||||
"""
|
||||
__tablename__ = "advertools_execution_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Task Reference
|
||||
task_id = Column(Integer, ForeignKey("advertools_tasks.id"), nullable=False, index=True)
|
||||
|
||||
# Execution Details
|
||||
execution_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
status = Column(String(50), nullable=False) # 'success', 'failed', 'skipped', 'running'
|
||||
|
||||
# Results
|
||||
result_data = Column(JSON, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
execution_time_ms = Column(Integer, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationship to task
|
||||
task = relationship("AdvertoolsTask", back_populates="execution_logs")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_advertools_execution_logs_task_date', 'task_id', 'execution_date'),
|
||||
Index('idx_advertools_execution_logs_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AdvertoolsExecutionLog(id={self.id}, task_id={self.task_id}, status={self.status}, execution_date={self.execution_date})>"
|
||||
109
backend/models/agent_activity_models.py
Normal file
109
backend/models/agent_activity_models.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, JSON, ForeignKey, Index, Float
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from models.enhanced_strategy_models import Base
|
||||
|
||||
|
||||
class AgentRun(Base):
|
||||
__tablename__ = "agent_runs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
agent_type = Column(String(100), nullable=False, index=True)
|
||||
prompt = Column(Text, nullable=True)
|
||||
status = Column(String(30), nullable=False, default="running", index=True)
|
||||
success = Column(Boolean, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
result_summary = Column(Text, nullable=True)
|
||||
mlflow_run_id = Column(String(255), nullable=True)
|
||||
started_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
finished_at = Column(DateTime, nullable=True, index=True)
|
||||
|
||||
events = relationship("AgentEvent", back_populates="run", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class AgentEvent(Base):
|
||||
__tablename__ = "agent_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
run_id = Column(Integer, ForeignKey("agent_runs.id"), nullable=True, index=True)
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
agent_type = Column(String(100), nullable=True, index=True)
|
||||
event_type = Column(String(50), nullable=False, index=True)
|
||||
severity = Column(String(20), nullable=False, default="info", index=True)
|
||||
message = Column(Text, nullable=True)
|
||||
payload = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
run = relationship("AgentRun", back_populates="events")
|
||||
|
||||
|
||||
class AgentAlert(Base):
|
||||
__tablename__ = "agent_alerts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
source = Column(String(30), nullable=False, default="agents", index=True)
|
||||
alert_type = Column(String(50), nullable=False, index=True)
|
||||
severity = Column(String(20), nullable=False, default="info", index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
message = Column(Text, nullable=False)
|
||||
cta_path = Column(String(255), nullable=True)
|
||||
payload = Column(JSON, nullable=True)
|
||||
dedupe_key = Column(String(255), nullable=True, index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
read_at = Column(DateTime, nullable=True, index=True)
|
||||
|
||||
|
||||
Index("ix_agent_alerts_user_unread", AgentAlert.user_id, AgentAlert.read_at)
|
||||
|
||||
|
||||
class AgentApprovalRequest(Base):
|
||||
__tablename__ = "agent_approval_requests"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
run_id = Column(Integer, ForeignKey("agent_runs.id"), nullable=True, index=True)
|
||||
agent_type = Column(String(100), nullable=True, index=True)
|
||||
action_id = Column(String(255), nullable=False, index=True)
|
||||
action_type = Column(String(255), nullable=False, index=True)
|
||||
target_resource = Column(String(255), nullable=True)
|
||||
risk_level = Column(Float, nullable=False, default=0.5)
|
||||
payload = Column(JSON, nullable=True)
|
||||
status = Column(String(30), nullable=False, default="pending", index=True)
|
||||
expires_at = Column(DateTime, nullable=True, index=True)
|
||||
decided_at = Column(DateTime, nullable=True, index=True)
|
||||
decision = Column(String(30), nullable=True)
|
||||
user_comments = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
Index("ix_agent_approval_user_status", AgentApprovalRequest.user_id, AgentApprovalRequest.status)
|
||||
|
||||
|
||||
class AgentProfile(Base):
|
||||
__tablename__ = "agent_profiles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
agent_key = Column(String(100), nullable=False, index=True)
|
||||
agent_type = Column(String(100), nullable=True, index=True)
|
||||
|
||||
display_name = Column(String(255), nullable=True)
|
||||
enabled = Column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
schedule = Column(JSON, nullable=True)
|
||||
notification_prefs = Column(JSON, nullable=True)
|
||||
|
||||
tone = Column(JSON, nullable=True)
|
||||
system_prompt = Column(Text, nullable=True)
|
||||
task_prompt_template = Column(Text, nullable=True)
|
||||
reporting_prefs = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
Index("ix_agent_profiles_user_key", AgentProfile.user_id, AgentProfile.agent_key, unique=True)
|
||||
@@ -30,10 +30,10 @@ class APIRequest(Base):
|
||||
|
||||
# Indexes for fast queries
|
||||
__table_args__ = (
|
||||
Index('idx_timestamp', 'timestamp'),
|
||||
Index('idx_path_method', 'path', 'method'),
|
||||
Index('idx_status_code', 'status_code'),
|
||||
Index('idx_user_id', 'user_id'),
|
||||
Index('idx_api_req_timestamp', 'timestamp'),
|
||||
Index('idx_api_req_path_method', 'path', 'method'),
|
||||
Index('idx_api_req_status_code', 'status_code'),
|
||||
Index('idx_api_req_user_id', 'user_id'),
|
||||
)
|
||||
|
||||
class APIEndpointStats(Base):
|
||||
@@ -56,9 +56,9 @@ class APIEndpointStats(Base):
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_endpoint', 'endpoint'),
|
||||
Index('idx_total_requests', 'total_requests'),
|
||||
Index('idx_avg_duration', 'avg_duration'),
|
||||
Index('idx_api_stats_endpoint', 'endpoint'),
|
||||
Index('idx_api_stats_total_requests', 'total_requests'),
|
||||
Index('idx_api_stats_avg_duration', 'avg_duration'),
|
||||
)
|
||||
|
||||
class SystemHealth(Base):
|
||||
@@ -78,8 +78,8 @@ class SystemHealth(Base):
|
||||
metrics = Column(JSON, nullable=True) # Additional metrics
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_timestamp', 'timestamp'),
|
||||
Index('idx_status', 'status'),
|
||||
Index('idx_sys_health_timestamp', 'timestamp'),
|
||||
Index('idx_sys_health_status', 'status'),
|
||||
)
|
||||
|
||||
class CachePerformance(Base):
|
||||
@@ -97,6 +97,6 @@ class CachePerformance(Base):
|
||||
total_requests = Column(Integer, default=0)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_timestamp', 'timestamp'),
|
||||
Index('idx_cache_perf_timestamp', 'timestamp'),
|
||||
Index('idx_cache_type', 'cache_type'),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
class BusinessInfoRequest(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
user_id: Optional[str] = None
|
||||
business_description: str = Field(..., min_length=10, max_length=1000, description="Description of the business")
|
||||
industry: Optional[str] = Field(None, max_length=100, description="Industry sector")
|
||||
target_audience: Optional[str] = Field(None, max_length=500, description="Target audience description")
|
||||
@@ -12,7 +12,7 @@ class BusinessInfoRequest(BaseModel):
|
||||
|
||||
class BusinessInfoResponse(BaseModel):
|
||||
id: int
|
||||
user_id: Optional[int]
|
||||
user_id: Optional[str]
|
||||
business_description: str
|
||||
industry: Optional[str]
|
||||
target_audience: Optional[str]
|
||||
|
||||
@@ -255,6 +255,8 @@ class StyleDetectionResponse(BaseModel):
|
||||
style_analysis: Optional[Dict[str, Any]] = None
|
||||
style_patterns: Optional[Dict[str, Any]] = None
|
||||
style_guidelines: Optional[Dict[str, Any]] = None
|
||||
seo_audit: Optional[Dict[str, Any]] = None
|
||||
sitemap_analysis: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
warning: Optional[str] = None
|
||||
timestamp: str
|
||||
34
backend/models/crawled_content.py
Normal file
34
backend/models/crawled_content.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, Index
|
||||
from datetime import datetime
|
||||
from models.enhanced_strategy_models import Base
|
||||
|
||||
class EndUserWebsiteContent(Base):
|
||||
"""
|
||||
Model for storing crawled content from the end user's website.
|
||||
"""
|
||||
__tablename__ = "end_user_website_content"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
website_url = Column(String(500), nullable=False, index=True)
|
||||
|
||||
# Page details
|
||||
url = Column(String(2048), nullable=False)
|
||||
title = Column(String(1000), nullable=True)
|
||||
content = Column(Text, nullable=True) # Main content
|
||||
raw_html = Column(Text, nullable=True) # Raw HTML if needed (maybe truncate or store separately)
|
||||
published_date = Column(DateTime, nullable=True)
|
||||
|
||||
# Metadata
|
||||
metadata_info = Column(JSON, nullable=True) # Any other metadata
|
||||
|
||||
# Crawl info
|
||||
crawled_at = Column(DateTime, default=datetime.utcnow)
|
||||
status_code = Column(Integer, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_end_user_website_content_user_url', 'user_id', 'url', mysql_length={'url': 255}),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EndUserWebsiteContent(id={self.id}, user_id={self.user_id}, url={self.url})>"
|
||||
49
backend/models/daily_workflow_models.py
Normal file
49
backend/models/daily_workflow_models.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, JSON, ForeignKey, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from models.enhanced_strategy_models import Base
|
||||
|
||||
|
||||
class DailyWorkflowPlan(Base):
|
||||
__tablename__ = "daily_workflow_plans"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
date = Column(String(10), nullable=False, index=True)
|
||||
source = Column(String(30), nullable=False, default="agent")
|
||||
plan_json = Column(JSON, nullable=True)
|
||||
generation_run_id = Column(Integer, nullable=True, index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
|
||||
|
||||
tasks = relationship("DailyWorkflowTask", back_populates="plan", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class DailyWorkflowTask(Base):
|
||||
__tablename__ = "daily_workflow_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
plan_id = Column(Integer, ForeignKey("daily_workflow_plans.id"), nullable=False, index=True)
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
pillar_id = Column(String(30), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
status = Column(String(30), nullable=False, default="pending", index=True)
|
||||
priority = Column(String(10), nullable=False, default="medium", index=True)
|
||||
estimated_time = Column(Integer, nullable=False, default=15)
|
||||
action_type = Column(String(20), nullable=False, default="navigate")
|
||||
action_url = Column(String(255), nullable=True)
|
||||
enabled = Column(Boolean, nullable=False, default=True)
|
||||
dependencies = Column(JSON, nullable=True)
|
||||
metadata_json = Column("metadata", JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
|
||||
decided_at = Column(DateTime, nullable=True, index=True)
|
||||
completion_notes = Column(Text, nullable=True)
|
||||
|
||||
plan = relationship("DailyWorkflowPlan", back_populates="tasks")
|
||||
|
||||
|
||||
Index("ix_daily_workflow_plans_user_date", DailyWorkflowPlan.user_id, DailyWorkflowPlan.date, unique=True)
|
||||
@@ -17,7 +17,7 @@ class EnhancedContentStrategy(Base):
|
||||
|
||||
# Primary fields
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, nullable=False)
|
||||
user_id = Column(String(255), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
industry = Column(String(100), nullable=True)
|
||||
|
||||
@@ -186,7 +186,7 @@ class EnhancedAIAnalysisResult(Base):
|
||||
__tablename__ = "enhanced_ai_analysis_results"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, nullable=False)
|
||||
user_id = Column(String(255), nullable=False)
|
||||
strategy_id = Column(Integer, ForeignKey("enhanced_content_strategies.id"), nullable=True)
|
||||
|
||||
# Analysis type for the 5 specialized prompts
|
||||
@@ -244,7 +244,7 @@ class OnboardingDataIntegration(Base):
|
||||
__tablename__ = "onboarding_data_integrations"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, nullable=False)
|
||||
user_id = Column(String(255), nullable=False)
|
||||
strategy_id = Column(Integer, ForeignKey("enhanced_content_strategies.id"), nullable=True)
|
||||
|
||||
# Legacy onboarding storage fields (match existing DB schema)
|
||||
@@ -275,6 +275,7 @@ class OnboardingDataIntegration(Base):
|
||||
'website_analysis_data': self.website_analysis_data,
|
||||
'research_preferences_data': self.research_preferences_data,
|
||||
'api_keys_data': self.api_keys_data,
|
||||
'canonical_profile': self.canonical_profile,
|
||||
'field_mappings': self.field_mappings,
|
||||
'auto_populated_fields': self.auto_populated_fields,
|
||||
'user_overrides': self.user_overrides,
|
||||
@@ -291,7 +292,7 @@ class ContentStrategyAutofillInsights(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
strategy_id = Column(Integer, ForeignKey("enhanced_content_strategies.id"), nullable=False)
|
||||
user_id = Column(Integer, nullable=False)
|
||||
user_id = Column(String(255), nullable=False)
|
||||
|
||||
# Full snapshot of accepted inputs and transparency at time of strategy creation/confirmation
|
||||
accepted_fields = Column(JSON, nullable=False)
|
||||
@@ -304,4 +305,4 @@ class ContentStrategyAutofillInsights(Base):
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationship back to strategy
|
||||
strategy = relationship("EnhancedContentStrategy", back_populates="autofill_insights")
|
||||
strategy = relationship("EnhancedContentStrategy", back_populates="autofill_insights")
|
||||
|
||||
@@ -65,7 +65,7 @@ class StrategyPerformanceMetrics(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
strategy_id = Column(Integer, ForeignKey("enhanced_content_strategies.id"), nullable=False)
|
||||
user_id = Column(Integer, nullable=False)
|
||||
user_id = Column(String(255), nullable=False)
|
||||
metric_date = Column(DateTime, default=datetime.utcnow)
|
||||
traffic_growth_percentage = Column(Integer, nullable=True)
|
||||
engagement_rate_percentage = Column(Integer, nullable=True)
|
||||
|
||||
@@ -52,11 +52,10 @@ class OAuthTokenMonitoringTask(Base):
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Indexes for efficient queries
|
||||
__table_args__ = (
|
||||
Index('idx_user_platform', 'user_id', 'platform'),
|
||||
Index('idx_next_check', 'next_check'),
|
||||
Index('idx_status', 'status'),
|
||||
Index('idx_oauth_token_tasks_user_platform', 'user_id', 'platform'),
|
||||
Index('idx_oauth_token_tasks_next_check', 'next_check'),
|
||||
Index('idx_oauth_token_tasks_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
@@ -91,10 +90,9 @@ class OAuthTokenExecutionLog(Base):
|
||||
# Relationship to task
|
||||
task = relationship("OAuthTokenMonitoringTask", back_populates="execution_logs")
|
||||
|
||||
# Indexes for efficient queries
|
||||
__table_args__ = (
|
||||
Index('idx_task_execution_date', 'task_id', 'execution_date'),
|
||||
Index('idx_status', 'status'),
|
||||
Index('idx_oauth_token_logs_task_execution_date', 'task_id', 'execution_date'),
|
||||
Index('idx_oauth_token_logs_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, func, JSON, Text, Boolean
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, func, JSON, Text, Boolean, UniqueConstraint
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
import datetime
|
||||
@@ -61,13 +61,16 @@ class WebsiteAnalysis(Base):
|
||||
target_audience = Column(JSON) # Demographics, expertise level, industry focus
|
||||
content_type = Column(JSON) # Primary type, secondary types, purpose
|
||||
recommended_settings = Column(JSON) # Writing tone, target audience, content type
|
||||
# brand_analysis = Column(JSON) # Brand voice, values, positioning, competitive differentiation
|
||||
# content_strategy_insights = Column(JSON) # SWOT analysis, strengths, weaknesses, opportunities, threats
|
||||
brand_analysis = Column(JSON) # Brand voice, values, positioning, competitive differentiation
|
||||
content_strategy_insights = Column(JSON) # SWOT analysis, strengths, weaknesses, opportunities, threats
|
||||
social_media_presence = Column(JSON) # Social media accounts and metrics
|
||||
|
||||
# Crawl results
|
||||
crawl_result = Column(JSON) # Raw crawl data
|
||||
style_patterns = Column(JSON) # Writing patterns analysis
|
||||
style_guidelines = Column(JSON) # Generated guidelines
|
||||
seo_audit = Column(JSON) # Comprehensive SEO audit results
|
||||
strategic_insights_history = Column(JSON) # Weekly strategic intelligence reports history
|
||||
|
||||
# Metadata
|
||||
status = Column(String(50), default='completed') # completed, failed, in_progress
|
||||
@@ -86,6 +89,7 @@ class WebsiteAnalysis(Base):
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'session_id': self.session_id,
|
||||
'website_url': self.website_url,
|
||||
'analysis_date': self.analysis_date.isoformat() if self.analysis_date else None,
|
||||
'writing_style': self.writing_style,
|
||||
@@ -93,11 +97,14 @@ class WebsiteAnalysis(Base):
|
||||
'target_audience': self.target_audience,
|
||||
'content_type': self.content_type,
|
||||
'recommended_settings': self.recommended_settings,
|
||||
# 'brand_analysis': self.brand_analysis,
|
||||
# 'content_strategy_insights': self.content_strategy_insights,
|
||||
'brand_analysis': self.brand_analysis,
|
||||
'content_strategy_insights': self.content_strategy_insights,
|
||||
'social_media_presence': self.social_media_presence,
|
||||
'crawl_result': self.crawl_result,
|
||||
'style_patterns': self.style_patterns,
|
||||
'style_guidelines': self.style_guidelines,
|
||||
'seo_audit': self.seo_audit,
|
||||
'strategic_insights_history': self.strategic_insights_history,
|
||||
'status': self.status,
|
||||
'error_message': self.error_message,
|
||||
'warning_message': self.warning_message,
|
||||
@@ -105,6 +112,50 @@ class WebsiteAnalysis(Base):
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
class SEOPageAudit(Base):
|
||||
__tablename__ = 'seo_page_audits'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'page_url', name='uq_seo_page_audits_user_page'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
website_url = Column(String(500), nullable=False, index=True)
|
||||
page_url = Column(String(1000), nullable=False, index=True)
|
||||
|
||||
overall_score = Column(Integer, nullable=True)
|
||||
status = Column(String(50), default='needs_review', index=True)
|
||||
|
||||
category_scores = Column(JSON)
|
||||
issues = Column(JSON)
|
||||
warnings = Column(JSON)
|
||||
recommendations = Column(JSON)
|
||||
audit_data = Column(JSON)
|
||||
|
||||
analysis_source = Column(String(50), default='onboarding_full_site')
|
||||
last_analyzed_at = Column(DateTime, default=func.now())
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'user_id': self.user_id,
|
||||
'website_url': self.website_url,
|
||||
'page_url': self.page_url,
|
||||
'overall_score': self.overall_score,
|
||||
'status': self.status,
|
||||
'category_scores': self.category_scores,
|
||||
'issues': self.issues,
|
||||
'warnings': self.warnings,
|
||||
'recommendations': self.recommendations,
|
||||
'audit_data': self.audit_data,
|
||||
'analysis_source': self.analysis_source,
|
||||
'last_analyzed_at': self.last_analyzed_at.isoformat() if self.last_analyzed_at else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
class ResearchPreferences(Base):
|
||||
"""Stores research preferences from onboarding step 3."""
|
||||
__tablename__ = 'research_preferences'
|
||||
@@ -197,7 +248,7 @@ class CompetitorAnalysis(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id = Column(Integer, ForeignKey('onboarding_sessions.id', ondelete='CASCADE'), nullable=False)
|
||||
competitor_url = Column(String(500), nullable=False)
|
||||
competitor_url = Column(Text, nullable=False)
|
||||
competitor_domain = Column(String(255), nullable=True) # Extracted domain for easier queries
|
||||
analysis_date = Column(DateTime, default=func.now())
|
||||
|
||||
@@ -231,4 +282,4 @@ class CompetitorAnalysis(Base):
|
||||
'warning_message': self.warning_message,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class PodcastProject(Base):
|
||||
|
||||
# Composite indexes for common query patterns
|
||||
__table_args__ = (
|
||||
Index('idx_user_status_created', 'user_id', 'status', 'created_at'),
|
||||
Index('idx_user_favorite_updated', 'user_id', 'is_favorite', 'updated_at'),
|
||||
Index('idx_podcast_user_status_created', 'user_id', 'status', 'created_at'),
|
||||
Index('idx_podcast_user_favorite_updated', 'user_id', 'is_favorite', 'updated_at'),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,8 +67,8 @@ class Campaign(Base):
|
||||
|
||||
# Composite indexes
|
||||
__table_args__ = (
|
||||
Index('idx_user_status', 'user_id', 'status'),
|
||||
Index('idx_user_created', 'user_id', 'created_at'),
|
||||
Index('idx_pm_campaign_user_status', 'user_id', 'status'),
|
||||
Index('idx_pm_campaign_user_created', 'user_id', 'created_at'),
|
||||
)
|
||||
|
||||
|
||||
@@ -109,10 +109,10 @@ class CampaignProposal(Base):
|
||||
campaign = relationship("Campaign", back_populates="proposals")
|
||||
generated_asset = relationship("CampaignAsset", back_populates="proposal", uselist=False)
|
||||
|
||||
# Composite indexes
|
||||
## Composite indexes
|
||||
__table_args__ = (
|
||||
Index('idx_campaign_node', 'campaign_id', 'asset_node_id'),
|
||||
Index('idx_user_status', 'user_id', 'status'),
|
||||
Index('idx_pm_proposal_campaign_node', 'campaign_id', 'asset_node_id'),
|
||||
Index('idx_pm_proposal_user_status', 'user_id', 'status'),
|
||||
)
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ class CampaignAsset(Base):
|
||||
|
||||
# Composite indexes
|
||||
__table_args__ = (
|
||||
Index('idx_campaign_node', 'campaign_id', 'asset_node_id'),
|
||||
Index('idx_user_status', 'user_id', 'status'),
|
||||
Index('idx_pm_asset_campaign_node', 'campaign_id', 'asset_node_id'),
|
||||
Index('idx_pm_asset_user_status', 'user_id', 'status'),
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +53,6 @@ class ResearchProject(Base):
|
||||
|
||||
# Composite indexes for common query patterns
|
||||
__table_args__ = (
|
||||
Index('idx_user_status_created', 'user_id', 'status', 'created_at'),
|
||||
Index('idx_user_favorite_updated', 'user_id', 'is_favorite', 'updated_at'),
|
||||
Index('idx_research_user_status_created', 'user_id', 'status', 'created_at'),
|
||||
Index('idx_research_user_favorite_updated', 'user_id', 'is_favorite', 'updated_at'),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ class UserBusinessInfo(Base):
|
||||
__tablename__ = 'user_business_info'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, index=True, nullable=True)
|
||||
user_id = Column(String(255), index=True, nullable=True)
|
||||
business_description = Column(Text, nullable=False)
|
||||
industry = Column(String(100), nullable=True)
|
||||
target_audience = Column(Text, nullable=True)
|
||||
|
||||
@@ -107,3 +107,344 @@ class WebsiteAnalysisExecutionLog(Base):
|
||||
def __repr__(self):
|
||||
return f"<WebsiteAnalysisExecutionLog(id={self.id}, task_id={self.task_id}, status={self.status}, execution_date={self.execution_date})>"
|
||||
|
||||
|
||||
class OnboardingFullWebsiteAnalysisTask(Base):
|
||||
__tablename__ = "onboarding_full_website_analysis_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
website_url = Column(String(500), nullable=False, index=True)
|
||||
|
||||
status = Column(String(50), default='active', index=True)
|
||||
|
||||
last_executed = Column(DateTime, nullable=True)
|
||||
last_success = Column(DateTime, nullable=True)
|
||||
last_failure = Column(DateTime, nullable=True)
|
||||
failure_reason = Column(Text, nullable=True)
|
||||
|
||||
consecutive_failures = Column(Integer, default=0)
|
||||
failure_pattern = Column(JSON, nullable=True)
|
||||
|
||||
next_execution = Column(DateTime, nullable=True, index=True)
|
||||
|
||||
payload = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
execution_logs = relationship(
|
||||
"OnboardingFullWebsiteAnalysisExecutionLog",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_onboarding_full_website_analysis_tasks_user_site', 'user_id', 'website_url'),
|
||||
Index('idx_onboarding_full_website_analysis_tasks_next_execution', 'next_execution'),
|
||||
Index('idx_onboarding_full_website_analysis_tasks_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OnboardingFullWebsiteAnalysisTask(id={self.id}, user_id={self.user_id}, url={self.website_url}, status={self.status})>"
|
||||
|
||||
|
||||
class OnboardingFullWebsiteAnalysisExecutionLog(Base):
|
||||
__tablename__ = "onboarding_full_website_analysis_execution_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
task_id = Column(Integer, ForeignKey("onboarding_full_website_analysis_tasks.id"), nullable=False, index=True)
|
||||
|
||||
execution_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
status = Column(String(50), nullable=False)
|
||||
|
||||
result_data = Column(JSON, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
execution_time_ms = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
task = relationship("OnboardingFullWebsiteAnalysisTask", back_populates="execution_logs")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_onboarding_full_website_analysis_execution_logs_task_date', 'task_id', 'execution_date'),
|
||||
Index('idx_onboarding_full_website_analysis_execution_logs_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<OnboardingFullWebsiteAnalysisExecutionLog(id={self.id}, task_id={self.task_id}, status={self.status}, execution_date={self.execution_date})>"
|
||||
|
||||
|
||||
class DeepCompetitorAnalysisTask(Base):
|
||||
__tablename__ = "deep_competitor_analysis_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
website_url = Column(String(500), nullable=False, index=True)
|
||||
|
||||
status = Column(String(50), default='active', index=True)
|
||||
|
||||
last_executed = Column(DateTime, nullable=True)
|
||||
last_success = Column(DateTime, nullable=True)
|
||||
last_failure = Column(DateTime, nullable=True)
|
||||
failure_reason = Column(Text, nullable=True)
|
||||
|
||||
consecutive_failures = Column(Integer, default=0)
|
||||
failure_pattern = Column(JSON, nullable=True)
|
||||
|
||||
next_execution = Column(DateTime, nullable=True, index=True)
|
||||
|
||||
payload = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
execution_logs = relationship(
|
||||
"DeepCompetitorAnalysisExecutionLog",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_deep_competitor_analysis_tasks_user_site', 'user_id', 'website_url'),
|
||||
Index('idx_deep_competitor_analysis_tasks_next_execution', 'next_execution'),
|
||||
Index('idx_deep_competitor_analysis_tasks_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DeepCompetitorAnalysisTask(id={self.id}, user_id={self.user_id}, url={self.website_url}, status={self.status})>"
|
||||
|
||||
|
||||
class DeepCompetitorAnalysisExecutionLog(Base):
|
||||
__tablename__ = "deep_competitor_analysis_execution_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
task_id = Column(Integer, ForeignKey("deep_competitor_analysis_tasks.id"), nullable=False, index=True)
|
||||
|
||||
execution_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
status = Column(String(50), nullable=False)
|
||||
|
||||
result_data = Column(JSON, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
execution_time_ms = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
task = relationship("DeepCompetitorAnalysisTask", back_populates="execution_logs")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_deep_competitor_analysis_execution_logs_task_date', 'task_id', 'execution_date'),
|
||||
Index('idx_deep_competitor_analysis_execution_logs_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DeepCompetitorAnalysisExecutionLog(id={self.id}, task_id={self.task_id}, status={self.status}, execution_date={self.execution_date})>"
|
||||
|
||||
|
||||
class DeepWebsiteCrawlTask(Base):
|
||||
__tablename__ = "deep_website_crawl_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
website_url = Column(String(500), nullable=False, index=True)
|
||||
|
||||
status = Column(String(50), default='active', index=True)
|
||||
|
||||
last_executed = Column(DateTime, nullable=True)
|
||||
last_success = Column(DateTime, nullable=True)
|
||||
last_failure = Column(DateTime, nullable=True)
|
||||
failure_reason = Column(Text, nullable=True)
|
||||
|
||||
consecutive_failures = Column(Integer, default=0)
|
||||
failure_pattern = Column(JSON, nullable=True)
|
||||
|
||||
next_execution = Column(DateTime, nullable=True, index=True)
|
||||
|
||||
payload = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
execution_logs = relationship(
|
||||
"DeepWebsiteCrawlExecutionLog",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_deep_website_crawl_tasks_user_site', 'user_id', 'website_url'),
|
||||
Index('idx_deep_website_crawl_tasks_next_execution', 'next_execution'),
|
||||
Index('idx_deep_website_crawl_tasks_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DeepWebsiteCrawlTask(id={self.id}, user_id={self.user_id}, url={self.website_url}, status={self.status})>"
|
||||
|
||||
|
||||
class DeepWebsiteCrawlExecutionLog(Base):
|
||||
__tablename__ = "deep_website_crawl_execution_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
task_id = Column(Integer, ForeignKey("deep_website_crawl_tasks.id"), nullable=False, index=True)
|
||||
|
||||
execution_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
status = Column(String(50), nullable=False)
|
||||
|
||||
result_data = Column(JSON, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
execution_time_ms = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
task = relationship("DeepWebsiteCrawlTask", back_populates="execution_logs")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_deep_website_crawl_execution_logs_task_date', 'task_id', 'execution_date'),
|
||||
Index('idx_deep_website_crawl_execution_logs_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DeepWebsiteCrawlExecutionLog(id={self.id}, task_id={self.task_id}, status={self.status}, execution_date={self.execution_date})>"
|
||||
|
||||
|
||||
class SIFIndexingTask(Base):
|
||||
__tablename__ = "sif_indexing_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
website_url = Column(String(500), nullable=False, index=True)
|
||||
|
||||
status = Column(String(50), default='active', index=True)
|
||||
|
||||
last_executed = Column(DateTime, nullable=True)
|
||||
last_success = Column(DateTime, nullable=True)
|
||||
last_failure = Column(DateTime, nullable=True)
|
||||
failure_reason = Column(Text, nullable=True)
|
||||
|
||||
consecutive_failures = Column(Integer, default=0)
|
||||
failure_pattern = Column(JSON, nullable=True)
|
||||
|
||||
next_execution = Column(DateTime, nullable=True, index=True)
|
||||
frequency_hours = Column(Integer, default=48) # Default 48 hours
|
||||
|
||||
payload = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
execution_logs = relationship(
|
||||
"SIFIndexingExecutionLog",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_sif_indexing_tasks_user_site', 'user_id', 'website_url'),
|
||||
Index('idx_sif_indexing_tasks_next_execution', 'next_execution'),
|
||||
Index('idx_sif_indexing_tasks_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SIFIndexingTask(id={self.id}, user_id={self.user_id}, url={self.website_url}, status={self.status})>"
|
||||
|
||||
|
||||
class SIFIndexingExecutionLog(Base):
|
||||
__tablename__ = "sif_indexing_execution_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
task_id = Column(Integer, ForeignKey("sif_indexing_tasks.id"), nullable=False, index=True)
|
||||
|
||||
execution_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
status = Column(String(50), nullable=False)
|
||||
|
||||
result_data = Column(JSON, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
execution_time_ms = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
task = relationship("SIFIndexingTask", back_populates="execution_logs")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_sif_indexing_execution_logs_task_date', 'task_id', 'execution_date'),
|
||||
Index('idx_sif_indexing_execution_logs_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SIFIndexingExecutionLog(id={self.id}, task_id={self.task_id}, status={self.status}, execution_date={self.execution_date})>"
|
||||
|
||||
|
||||
class MarketTrendsTask(Base):
|
||||
__tablename__ = "market_trends_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
website_url = Column(String(500), nullable=False, index=True)
|
||||
|
||||
status = Column(String(50), default="active", index=True)
|
||||
|
||||
last_executed = Column(DateTime, nullable=True)
|
||||
last_success = Column(DateTime, nullable=True)
|
||||
last_failure = Column(DateTime, nullable=True)
|
||||
failure_reason = Column(Text, nullable=True)
|
||||
|
||||
consecutive_failures = Column(Integer, default=0)
|
||||
failure_pattern = Column(JSON, nullable=True)
|
||||
|
||||
next_execution = Column(DateTime, nullable=True, index=True)
|
||||
frequency_hours = Column(Integer, default=72)
|
||||
|
||||
payload = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
execution_logs = relationship(
|
||||
"MarketTrendsExecutionLog",
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_market_trends_tasks_user_site", "user_id", "website_url"),
|
||||
Index("idx_market_trends_tasks_next_execution", "next_execution"),
|
||||
Index("idx_market_trends_tasks_status", "status"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MarketTrendsTask(id={self.id}, user_id={self.user_id}, url={self.website_url}, status={self.status})>"
|
||||
|
||||
|
||||
class MarketTrendsExecutionLog(Base):
|
||||
__tablename__ = "market_trends_execution_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
task_id = Column(Integer, ForeignKey("market_trends_tasks.id"), nullable=False, index=True)
|
||||
|
||||
execution_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
status = Column(String(50), nullable=False)
|
||||
|
||||
result_data = Column(JSON, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
execution_time_ms = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
task = relationship("MarketTrendsTask", back_populates="execution_logs")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_market_trends_execution_logs_task_date", "task_id", "execution_date"),
|
||||
Index("idx_market_trends_execution_logs_status", "status"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MarketTrendsExecutionLog(id={self.id}, task_id={self.task_id}, status={self.status}, execution_date={self.execution_date})>"
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@copilotkit/react-core": "^1.10.6",
|
||||
"@copilotkit/react-textarea": "^1.10.6",
|
||||
"@copilotkit/react-ui": "^1.10.6"
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
# Render deployment configuration for ALwrity Backend
|
||||
services:
|
||||
- type: web
|
||||
name: alwrity-backend
|
||||
env: python
|
||||
buildCommand: pip install -r requirements.txt && python -m spacy download en_core_web_sm && python -m nltk.downloader punkt_tab stopwords averaged_perceptron_tagger
|
||||
startCommand: python start_alwrity_backend.py --production
|
||||
healthCheckPath: /health
|
||||
envVars:
|
||||
- key: DEPLOY_ENV
|
||||
value: render
|
||||
- key: HOST
|
||||
value: 0.0.0.0
|
||||
- key: PORT
|
||||
value: 8000
|
||||
- key: RELOAD
|
||||
value: false
|
||||
- key: LOG_LEVEL
|
||||
value: INFO
|
||||
- key: PYTHONPATH
|
||||
value: /opt/render/project/src/backend
|
||||
- key: TMPDIR
|
||||
value: /tmp
|
||||
# Add your environment variables here:
|
||||
# - key: GEMINI_API_KEY
|
||||
# value: your_gemini_key_here
|
||||
# - key: OPENAI_API_KEY
|
||||
# value: your_openai_key_here
|
||||
# - key: ANTHROPIC_API_KEY
|
||||
# value: your_anthropic_key_here
|
||||
# - key: MISTRAL_API_KEY
|
||||
# value: your_mistral_key_here
|
||||
# - key: TAVILY_API_KEY
|
||||
# value: your_tavily_key_here
|
||||
# - key: EXA_API_KEY
|
||||
# value: your_exa_key_here
|
||||
# - key: SERPER_API_KEY
|
||||
# value: your_serper_key_here
|
||||
# - key: CLERK_SECRET_KEY
|
||||
# value: your_clerk_secret_here
|
||||
# - key: GSC_REDIRECT_URI
|
||||
# value: https://your-frontend.vercel.app/gsc/callback
|
||||
# - key: WORDPRESS_REDIRECT_URI
|
||||
# value: https://your-frontend.vercel.app/wp/callback
|
||||
# - key: WIX_REDIRECT_URI
|
||||
# value: https://your-frontend.vercel.app/wix/callback
|
||||
@@ -19,9 +19,13 @@ copilotkit
|
||||
exa-py==1.9.1
|
||||
httpx>=0.27.2,<0.28.0
|
||||
|
||||
# AI/ML dependencies
|
||||
# AI/ML dependencies - Windows-compatible versions
|
||||
openai>=1.3.0
|
||||
google-genai>=1.0.0
|
||||
sentence-transformers>=2.2.2
|
||||
|
||||
# txtai with Windows-compatible dependencies
|
||||
txtai[agent]>=7.0.0
|
||||
|
||||
|
||||
google-api-python-client>=2.100.0
|
||||
@@ -78,4 +82,5 @@ apscheduler>=3.10.0
|
||||
|
||||
# Optional dependencies (for enhanced features)
|
||||
redis>=5.0.0
|
||||
schedule>=1.2.0
|
||||
schedule>=1.2.0
|
||||
pytrends>=4.9.0
|
||||
|
||||
@@ -19,8 +19,11 @@ from middleware.auth_middleware import get_current_user
|
||||
router = APIRouter(prefix="/bing-analytics", tags=["Bing Analytics Storage"])
|
||||
|
||||
# Initialize storage service
|
||||
DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///./bing_analytics.db')
|
||||
storage_service = BingAnalyticsStorageService(DATABASE_URL)
|
||||
from services.database import get_user_db_path
|
||||
|
||||
def get_storage_service(user_id: str) -> BingAnalyticsStorageService:
|
||||
"""Get storage service instance for a specific user."""
|
||||
return BingAnalyticsStorageService()
|
||||
|
||||
|
||||
@router.post("/collect-data")
|
||||
@@ -41,6 +44,8 @@ async def collect_bing_data(
|
||||
|
||||
logger.info(f"Starting Bing data collection for user {user_id}, site: {site_url}")
|
||||
|
||||
storage_service = get_storage_service(user_id)
|
||||
|
||||
# Run data collection in background
|
||||
background_tasks.add_task(
|
||||
storage_service.collect_and_store_data,
|
||||
@@ -80,6 +85,7 @@ async def get_analytics_summary(
|
||||
|
||||
logger.info(f"Getting analytics summary for user {user_id}, site: {site_url}, days: {days}")
|
||||
|
||||
storage_service = get_storage_service(user_id)
|
||||
summary = storage_service.get_analytics_summary(
|
||||
user_id=user_id,
|
||||
site_url=site_url,
|
||||
@@ -119,6 +125,7 @@ async def get_daily_metrics(
|
||||
|
||||
logger.info(f"Getting daily metrics for user {user_id}, site: {site_url}, days: {days}")
|
||||
|
||||
storage_service = get_storage_service(user_id)
|
||||
db = storage_service._get_db_session()
|
||||
|
||||
# Calculate date range
|
||||
@@ -190,6 +197,7 @@ async def get_top_queries(
|
||||
|
||||
logger.info(f"Getting top queries for user {user_id}, site: {site_url}, sort_by: {sort_by}")
|
||||
|
||||
storage_service = get_storage_service(user_id)
|
||||
db = storage_service._get_db_session()
|
||||
|
||||
# Calculate date range
|
||||
@@ -431,6 +439,8 @@ async def generate_daily_metrics(
|
||||
|
||||
logger.info(f"Generating daily metrics for user {user_id}, site: {site_url}, date: {target_dt}")
|
||||
|
||||
storage_service = get_storage_service(user_id)
|
||||
|
||||
# Run in background
|
||||
background_tasks.add_task(
|
||||
storage_service.generate_daily_metrics,
|
||||
|
||||
@@ -16,8 +16,10 @@ from middleware.auth_middleware import get_current_user
|
||||
router = APIRouter(prefix="/api/bing-insights", tags=["Bing Insights"])
|
||||
|
||||
# Initialize insights service
|
||||
DATABASE_URL = os.getenv('DATABASE_URL', 'sqlite:///./bing_analytics.db')
|
||||
insights_service = BingInsightsService(DATABASE_URL)
|
||||
from services.database import get_user_db_path
|
||||
|
||||
def get_insights_service(user_id: str) -> BingInsightsService:
|
||||
return BingInsightsService()
|
||||
|
||||
|
||||
@router.get("/performance")
|
||||
@@ -36,6 +38,7 @@ async def get_performance_insights(
|
||||
|
||||
logger.info(f"Getting performance insights for user {user_id}, site: {site_url}")
|
||||
|
||||
insights_service = get_insights_service(user_id)
|
||||
insights = insights_service.get_performance_insights(user_id, site_url, days)
|
||||
|
||||
if 'error' in insights:
|
||||
@@ -72,6 +75,7 @@ async def get_seo_insights(
|
||||
|
||||
logger.info(f"Getting SEO insights for user {user_id}, site: {site_url}")
|
||||
|
||||
insights_service = get_insights_service(user_id)
|
||||
insights = insights_service.get_seo_insights(user_id, site_url, days)
|
||||
|
||||
if 'error' in insights:
|
||||
@@ -181,6 +185,7 @@ async def get_comprehensive_insights(
|
||||
logger.info(f"Getting comprehensive insights for user {user_id}, site: {site_url}")
|
||||
|
||||
# Get all types of insights
|
||||
insights_service = get_insights_service(user_id)
|
||||
performance = insights_service.get_performance_insights(user_id, site_url, days)
|
||||
seo = insights_service.get_seo_insights(user_id, site_url, days)
|
||||
competitive = insights_service.get_competitive_insights(user_id, site_url, days)
|
||||
|
||||
@@ -28,7 +28,10 @@ from services.seo_tools.on_page_seo_service import OnPageSEOService
|
||||
from services.seo_tools.technical_seo_service import TechnicalSEOService
|
||||
from services.seo_tools.enterprise_seo_service import EnterpriseSEOService
|
||||
from services.seo_tools.content_strategy_service import ContentStrategyService
|
||||
from services.database import get_session_for_user
|
||||
from api.content_planning.services.content_strategy.onboarding import OnboardingDataIntegrationService
|
||||
from middleware.logging_middleware import log_api_call, save_to_file
|
||||
from middleware.auth_middleware import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/seo", tags=["AI SEO Tools"])
|
||||
|
||||
@@ -113,6 +116,10 @@ class WorkflowRequest(BaseModel):
|
||||
target_keywords: Optional[List[str]] = Field(None, description="Target keywords")
|
||||
custom_parameters: Optional[Dict[str, Any]] = Field(None, description="Custom workflow parameters")
|
||||
|
||||
class CompetitiveSitemapBenchmarkingRunRequest(BaseModel):
|
||||
max_competitors: int = Field(default=5, ge=1, le=10, description="Max competitors to analyze")
|
||||
competitors: Optional[List[HttpUrl]] = Field(None, description="Optional explicit competitor URLs")
|
||||
|
||||
# Exception Handler
|
||||
async def handle_seo_tool_exception(func_name: str, error: Exception, request_data: Dict) -> ErrorResponse:
|
||||
"""Handle exceptions from SEO tools with intelligent logging"""
|
||||
@@ -150,7 +157,8 @@ async def handle_seo_tool_exception(func_name: str, error: Exception, request_da
|
||||
@log_api_call
|
||||
async def generate_meta_description(
|
||||
request: MetaDescriptionRequest,
|
||||
background_tasks: BackgroundTasks
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> Union[BaseResponse, ErrorResponse]:
|
||||
"""
|
||||
Generate AI-powered SEO meta descriptions
|
||||
@@ -161,13 +169,15 @@ async def generate_meta_description(
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
user_id = str(current_user.get("id")) if current_user else None
|
||||
service = MetaDescriptionService()
|
||||
result = await service.generate_meta_description(
|
||||
keywords=request.keywords,
|
||||
tone=request.tone,
|
||||
search_intent=request.search_intent,
|
||||
language=request.language,
|
||||
custom_prompt=request.custom_prompt
|
||||
custom_prompt=request.custom_prompt,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
execution_time = (datetime.utcnow() - start_time).total_seconds()
|
||||
@@ -197,7 +207,8 @@ async def generate_meta_description(
|
||||
@log_api_call
|
||||
async def analyze_pagespeed(
|
||||
request: PageSpeedRequest,
|
||||
background_tasks: BackgroundTasks
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> Union[BaseResponse, ErrorResponse]:
|
||||
"""
|
||||
Analyze website performance using Google PageSpeed Insights
|
||||
@@ -208,12 +219,14 @@ async def analyze_pagespeed(
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
user_id = str(current_user.get("id")) if current_user else None
|
||||
service = PageSpeedService()
|
||||
result = await service.analyze_pagespeed(
|
||||
url=str(request.url),
|
||||
strategy=request.strategy,
|
||||
locale=request.locale,
|
||||
categories=request.categories
|
||||
categories=request.categories,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
execution_time = (datetime.utcnow() - start_time).total_seconds()
|
||||
@@ -243,7 +256,8 @@ async def analyze_pagespeed(
|
||||
@log_api_call
|
||||
async def analyze_sitemap(
|
||||
request: SitemapAnalysisRequest,
|
||||
background_tasks: BackgroundTasks
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> Union[BaseResponse, ErrorResponse]:
|
||||
"""
|
||||
Analyze website sitemap for content structure and trends
|
||||
@@ -254,11 +268,13 @@ async def analyze_sitemap(
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
user_id = str(current_user.get("id")) if current_user else None
|
||||
service = SitemapService()
|
||||
result = await service.analyze_sitemap(
|
||||
sitemap_url=str(request.sitemap_url),
|
||||
analyze_content_trends=request.analyze_content_trends,
|
||||
analyze_publishing_patterns=request.analyze_publishing_patterns
|
||||
analyze_publishing_patterns=request.analyze_publishing_patterns,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
execution_time = (datetime.utcnow() - start_time).total_seconds()
|
||||
@@ -538,7 +554,8 @@ async def execute_website_audit(
|
||||
@log_api_call
|
||||
async def execute_content_analysis(
|
||||
request: WorkflowRequest,
|
||||
background_tasks: BackgroundTasks
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> Union[BaseResponse, ErrorResponse]:
|
||||
"""
|
||||
AI-powered content analysis workflow
|
||||
@@ -549,12 +566,14 @@ async def execute_content_analysis(
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
user_id = str(current_user.get("id")) if current_user else None
|
||||
service = ContentStrategyService()
|
||||
result = await service.analyze_content_strategy(
|
||||
website_url=str(request.website_url),
|
||||
competitors=[str(comp) for comp in request.competitors] if request.competitors else [],
|
||||
target_keywords=request.target_keywords or [],
|
||||
custom_parameters=request.custom_parameters or {}
|
||||
custom_parameters=request.custom_parameters or {},
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
execution_time = (datetime.utcnow() - start_time).total_seconds()
|
||||
@@ -580,6 +599,164 @@ async def execute_content_analysis(
|
||||
except Exception as e:
|
||||
return await handle_seo_tool_exception("execute_content_analysis", e, request.dict())
|
||||
|
||||
# Background Task for Sitemap Benchmarking
|
||||
async def _run_sitemap_benchmark_background(
|
||||
user_id: str,
|
||||
website_url: str,
|
||||
competitors: List[str],
|
||||
max_competitors: int
|
||||
):
|
||||
"""Background task for running sitemap benchmarking"""
|
||||
logger.info(f"Starting background sitemap benchmark for user {user_id}")
|
||||
|
||||
# Create a new session for the background task
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
logger.error(f"Failed to get database session for user {user_id}")
|
||||
return
|
||||
|
||||
try:
|
||||
service = ContentStrategyService()
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
|
||||
# Run analysis (long running)
|
||||
report = await service.analyze_competitive_sitemap_benchmarking(
|
||||
website_url=website_url,
|
||||
competitors=competitors,
|
||||
max_competitors=max_competitors,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Persist results
|
||||
persisted = await integration_service.store_competitive_sitemap_benchmarking(user_id, report, db)
|
||||
|
||||
if persisted:
|
||||
logger.info(f"✅ Background sitemap benchmark completed and saved for user {user_id}")
|
||||
else:
|
||||
logger.error(f"❌ Failed to persist background sitemap benchmark for user {user_id}")
|
||||
await integration_service.update_competitive_sitemap_benchmarking_status(user_id, "failed", db, error="Failed to persist results")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in background sitemap benchmark for user {user_id}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
try:
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
await integration_service.update_competitive_sitemap_benchmarking_status(user_id, "failed", db, error=str(e))
|
||||
except Exception as update_err:
|
||||
logger.error(f"Failed to update error status: {update_err}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/competitive-sitemap-benchmarking/run", response_model=BaseResponse)
|
||||
@log_api_call
|
||||
async def run_competitive_sitemap_benchmarking(
|
||||
request: CompetitiveSitemapBenchmarkingRunRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> Union[BaseResponse, ErrorResponse]:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
user_id = str(current_user.get("id")) if current_user else None
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Get initial data to validate request
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
try:
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated = integration_service.get_integrated_data_sync(user_id, db)
|
||||
website_analysis = integrated.get("website_analysis") if isinstance(integrated, dict) else {}
|
||||
website_url = website_analysis.get("website_url") if isinstance(website_analysis, dict) else None
|
||||
|
||||
competitor_urls: List[str] = []
|
||||
if request.competitors:
|
||||
competitor_urls = [str(c) for c in request.competitors]
|
||||
else:
|
||||
competitor_analysis = integrated.get("competitor_analysis") if isinstance(integrated, dict) else []
|
||||
if isinstance(competitor_analysis, list):
|
||||
for comp in competitor_analysis:
|
||||
if not isinstance(comp, dict):
|
||||
continue
|
||||
url = comp.get("competitor_url") or comp.get("url") or comp.get("website_url")
|
||||
if url:
|
||||
competitor_urls.append(str(url))
|
||||
|
||||
if not website_url:
|
||||
raise HTTPException(status_code=400, detail="No website_url found. Complete onboarding step 2 first.")
|
||||
|
||||
# Set status to processing
|
||||
await integration_service.update_competitive_sitemap_benchmarking_status(user_id, "processing", db)
|
||||
|
||||
# Queue background task
|
||||
background_tasks.add_task(
|
||||
_run_sitemap_benchmark_background,
|
||||
user_id=user_id,
|
||||
website_url=str(website_url),
|
||||
competitors=competitor_urls,
|
||||
max_competitors=request.max_competitors
|
||||
)
|
||||
|
||||
execution_time = (datetime.utcnow() - start_time).total_seconds()
|
||||
|
||||
return BaseResponse(
|
||||
success=True,
|
||||
message="Competitive sitemap benchmarking started in background",
|
||||
execution_time=execution_time,
|
||||
data={
|
||||
"status": "queued",
|
||||
"competitors_count": len(competitor_urls)
|
||||
}
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
return await handle_seo_tool_exception("run_competitive_sitemap_benchmarking", e, request.dict())
|
||||
|
||||
@router.get("/competitive-sitemap-benchmarking", response_model=BaseResponse)
|
||||
@log_api_call
|
||||
async def get_competitive_sitemap_benchmarking(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
) -> Union[BaseResponse, ErrorResponse]:
|
||||
try:
|
||||
user_id = str(current_user.get("id")) if current_user else None
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database connection failed")
|
||||
|
||||
try:
|
||||
integration_service = OnboardingDataIntegrationService()
|
||||
integrated = integration_service.get_integrated_data_sync(user_id, db)
|
||||
website_analysis = integrated.get("website_analysis") if isinstance(integrated, dict) else {}
|
||||
seo_audit = website_analysis.get("seo_audit") if isinstance(website_analysis, dict) else {}
|
||||
report = seo_audit.get("competitive_sitemap_benchmarking") if isinstance(seo_audit, dict) else None
|
||||
|
||||
return BaseResponse(
|
||||
success=True,
|
||||
message="Competitive sitemap benchmarking loaded",
|
||||
data={
|
||||
"report": report
|
||||
}
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
return await handle_seo_tool_exception("get_competitive_sitemap_benchmarking", e, {})
|
||||
|
||||
# Health and Status Endpoints
|
||||
|
||||
@router.get("/health", response_model=BaseResponse)
|
||||
@@ -650,4 +827,4 @@ async def get_tools_status() -> BaseResponse:
|
||||
"tools": tools_status,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,37 +1,54 @@
|
||||
"""
|
||||
Database Migration Script for Billing System
|
||||
Creates all tables needed for billing, usage tracking, and subscription management.
|
||||
Supports multi-tenant architecture.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Add the backend directory to Python path
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy import create_engine, text, inspect
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from loguru import logger
|
||||
import traceback
|
||||
|
||||
# Import models
|
||||
from models.subscription_models import Base as SubscriptionBase
|
||||
from services.database import DATABASE_URL
|
||||
from services.database import get_engine_for_user, get_all_user_ids, init_user_database
|
||||
from services.subscription.pricing_service import PricingService
|
||||
|
||||
def create_billing_tables():
|
||||
"""Create all billing and subscription-related tables."""
|
||||
def check_existing_tables(engine):
|
||||
"""Check if billing tables exist."""
|
||||
if engine is None:
|
||||
return False
|
||||
try:
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
# Check for a key table
|
||||
return 'subscription_plans' in tables
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking existing tables: {e}")
|
||||
return False
|
||||
|
||||
def create_billing_tables(user_id):
|
||||
"""Create all billing and subscription-related tables for a specific user."""
|
||||
|
||||
try:
|
||||
# Create engine
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
logger.info(f"Setting up billing tables for user: {user_id}")
|
||||
|
||||
# Create all tables
|
||||
# Get engine for user
|
||||
engine = get_engine_for_user(user_id)
|
||||
|
||||
# Create all tables (idempotent)
|
||||
logger.debug("Creating billing and subscription system tables...")
|
||||
SubscriptionBase.metadata.create_all(bind=engine)
|
||||
logger.debug("✅ Billing and subscription tables created successfully")
|
||||
logger.debug("✅ Billing and subscription tables created/verified")
|
||||
|
||||
# Create session for data initialization
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
@@ -49,6 +66,8 @@ def create_billing_tables():
|
||||
pricing_service.initialize_default_plans()
|
||||
logger.debug("✅ Default subscription plans initialized")
|
||||
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing default data: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -57,15 +76,17 @@ def create_billing_tables():
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info("✅ Billing system setup completed successfully!")
|
||||
logger.info(f"✅ Billing system setup completed successfully for {user_id}!")
|
||||
|
||||
# Display summary
|
||||
display_setup_summary(engine)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error creating billing tables: {e}")
|
||||
logger.error(f"❌ Error creating billing tables for {user_id}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
return False
|
||||
|
||||
def display_setup_summary(engine):
|
||||
"""Display a summary of the created tables and data."""
|
||||
@@ -144,74 +165,36 @@ def display_setup_summary(engine):
|
||||
logger.warning(f"Could not check API pricing: {e}")
|
||||
|
||||
logger.info("\n" + "="*60)
|
||||
logger.info("NEXT STEPS:")
|
||||
logger.info("="*60)
|
||||
logger.info("1. Billing system is ready for use")
|
||||
logger.info("2. API endpoints are available at:")
|
||||
logger.info(" GET /api/subscription/plans")
|
||||
logger.info(" GET /api/subscription/usage/{user_id}")
|
||||
logger.info(" GET /api/subscription/dashboard/{user_id}")
|
||||
logger.info(" GET /api/subscription/pricing")
|
||||
logger.info("\n3. Frontend billing dashboard is integrated")
|
||||
logger.info("4. Usage tracking middleware is active")
|
||||
logger.info("5. Real-time cost monitoring is enabled")
|
||||
logger.info("="*60)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error displaying summary: {e}")
|
||||
|
||||
def check_existing_tables(engine):
|
||||
"""Check if billing tables already exist."""
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
# Check for billing tables
|
||||
check_query = text("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND (
|
||||
name = 'subscription_plans' OR
|
||||
name = 'user_subscriptions' OR
|
||||
name = 'api_usage_logs' OR
|
||||
name = 'usage_summaries' OR
|
||||
name = 'api_provider_pricing' OR
|
||||
name = 'usage_alerts'
|
||||
)
|
||||
""")
|
||||
|
||||
result = conn.execute(check_query)
|
||||
existing_tables = result.fetchall()
|
||||
|
||||
if existing_tables:
|
||||
logger.warning(f"Found existing billing tables: {[t[0] for t in existing_tables]}")
|
||||
logger.debug("Tables already exist. Skipping creation to preserve data.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking existing tables: {e}")
|
||||
return True # Proceed anyway
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.debug("🚀 Starting billing system database migration...")
|
||||
parser = argparse.ArgumentParser(description='Create billing tables for a user.')
|
||||
parser.add_argument('--user_id', type=str, help='Specific user ID to setup billing for')
|
||||
parser.add_argument('--all', action='store_true', help='Setup billing for ALL users')
|
||||
|
||||
try:
|
||||
# Create engine to check existing tables
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.user_id:
|
||||
create_billing_tables(args.user_id)
|
||||
elif args.all:
|
||||
user_ids = get_all_user_ids()
|
||||
logger.info(f"Found {len(user_ids)} users to process")
|
||||
for uid in user_ids:
|
||||
create_billing_tables(uid)
|
||||
else:
|
||||
logger.warning("No user_id provided. Using default behavior (checking for single tenant or exiting).")
|
||||
logger.warning("Usage: python create_billing_tables.py --user_id <user_id> OR --all")
|
||||
|
||||
# Check existing tables
|
||||
if not check_existing_tables(engine):
|
||||
logger.debug("✅ Billing tables already exist, skipping creation")
|
||||
sys.exit(0)
|
||||
|
||||
# Create tables and initialize data
|
||||
create_billing_tables()
|
||||
|
||||
logger.info("✅ Billing system migration completed successfully!")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Migration cancelled by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Migration failed: {e}")
|
||||
sys.exit(1)
|
||||
# Fallback: if there's only one user, maybe we can guess?
|
||||
# But safer to just exit or ask for input.
|
||||
# For now, let's try to discover users and if only 1, do it.
|
||||
user_ids = get_all_user_ids()
|
||||
if len(user_ids) == 1:
|
||||
logger.info(f"Single user found: {user_ids[0]}. Proceeding...")
|
||||
create_billing_tables(user_ids[0])
|
||||
elif len(user_ids) > 1:
|
||||
logger.error(f"Multiple users found {user_ids}. Please specify --user_id or --all")
|
||||
else:
|
||||
logger.error("No users found.")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user