From 6fd7213076c9bf8faa7ef4811d193b4cd2df8f40 Mon Sep 17 00:00:00 2001 From: Michael Ikehi Date: Thu, 17 Apr 2025 08:50:12 +0100 Subject: [PATCH] feat: Initial implementation of Marketing Assistant AI for Adriana James - Set up FastAPI backend with modular structure: - main.py for API routing - copywriter.py for AI-powered content generation using Cohere - embeddings.py for generating and reranking content embeddings - vector_store.py for FAISS-based similarity search - brand_style.py for managing brand tone, taboo words, and preferred terms - config.py for managing environment and application settings - Configured RESTful API endpoints: /generate-copy, /brand-style, /training-data, /improve-content, /analyze-content - Created frontend with vanilla HTML, CSS, and JS (index.html, styles.css, app.js) - Integrated brand style management for tone, voice, taboo words, and terminology - Implemented vector search for referencing similar historical content - Enabled training data input to improve future AI output - Added environment variable support for API keys and model configs - Structured data storage with local JSON and DB files - Added developer documentation, API reference, and project setup instructions This commit provides the foundation for a full-stack, AI-driven content creation platform that ensures brand consistency, speeds up marketing workflows, and supports iterative improvement over time. --- .gitignore | 44 + LICENCE | 21 + README.md | 247 ++++++ backend/brand_style.py | 175 ++++ backend/config.py | 87 ++ backend/copywriter.py | 278 +++++++ backend/data/vector_store/faiss_index.bin | Bin 0 -> 45101 bytes backend/data/vector_store/metadata.pkl | Bin 0 -> 12686 bytes backend/embeddings.py | 138 ++++ backend/main.py | 406 +++++++++ backend/requirements.txt | 15 + backend/vector_store.py | 347 ++++++++ data/README.md | 81 ++ data/past_campaigns/.gitkeep | 0 data/style_guidelines/brand_style.json | 36 + data/user_queries/.gitkeep | 0 docs/API_DOCUMENTATION.md | 187 +++++ frontend/app.js | 513 ++++++++++++ frontend/index.html | 498 +++++++++++ frontend/styles.css | 964 ++++++++++++++++++++++ logs/app.log | 460 +++++++++++ 21 files changed, 4497 insertions(+) create mode 100644 .gitignore create mode 100644 LICENCE create mode 100644 README.md create mode 100644 backend/brand_style.py create mode 100644 backend/config.py create mode 100644 backend/copywriter.py create mode 100644 backend/data/vector_store/faiss_index.bin create mode 100644 backend/data/vector_store/metadata.pkl create mode 100644 backend/embeddings.py create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 backend/vector_store.py create mode 100644 data/README.md create mode 100644 data/past_campaigns/.gitkeep create mode 100644 data/style_guidelines/brand_style.json create mode 100644 data/user_queries/.gitkeep create mode 100644 docs/API_DOCUMENTATION.md create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/styles.css create mode 100644 logs/app.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa2ff0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Environment variables +.env + +# Virtual Environment +venv/ +ENV/ + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo + +# Local data +data/past_campaigns/* +data/user_queries/* +!data/past_campaigns/.gitkeep +!data/user_queries/.gitkeep + +# OS specific +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..c83ee5e --- /dev/null +++ b/LICENCE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Adriana James + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9dbd6d1 --- /dev/null +++ b/README.md @@ -0,0 +1,247 @@ +# Marketing Assistant AI + +A comprehensive AI-powered tool designed to streamline the process of ideation, copywriting, and marketing campaign creation for Adriana James. This system generates high-quality marketing content that maintains brand consistency while significantly reducing content creation time. + + +## 🌟 Features + +- **AI-Powered Content Generation**: Create marketing content for various channels with customizable parameters +- **Brand Voice Consistency**: Ensure all content adheres to Adriana James' unique brand tone and style +- **Vector Search**: Find similar past content for reference and inspiration +- **Content Improvement**: Refine generated content based on specific feedback +- **Brand Style Management**: Configure and update brand guidelines through a user-friendly interface +- **Training Data Management**: Add successful marketing content to improve the AI over time +- **Content Performance Analysis**: Get insights into the potential effectiveness of your content + +## 📋 Project Overview + +The Marketing Assistant AI combines a powerful backend built with FastAPI and a clean, intuitive frontend interface. It leverages advanced AI models through the Cohere API for text generation, embeddings, and reranking. + +### Tech Stack + +- **Backend**: Python with FastAPI +- **Frontend**: Vanilla HTML, CSS, and JavaScript +- **AI Models**: Cohere's generation and embedding models +- **Vector Database**: FAISS for efficient content similarity search +- **Storage**: Local JSON files for historical marketing data + +## 🚀 Getting Started + +### Prerequisites + +- Python 3.9+ +- Node.js and npm (optional, for development tools) +- Cohere API key + +### Installation + +1. **Clone the repository** + +```bash +git clone http://23.29.118.76:3000/michael/marketing-assistant-ai.git +cd marketing-assistant-ai +``` + +2. **Set up the backend** + +```bash +cd backend +pip install -r requirements.txt +``` + +3. **Configure environment variables** + +Create a `.env` file in the project root with the following variables: + +``` +# API Keys +COHERE_API_KEY=your-api-key-here + +# LLM Configuration +LLM_MODEL=llm-model-name # e.g., command +LLM_API_KEY=your-api-key-here # if using a custom model + +# Server Configuration +API_HOST=localhost +API_PORT=8000 + +# Database Configuration +VECTOR_DB_PATH=./data/vector_store +HISTORY_DB_PATH=./data/history.db + +# Brand Configuration +BRAND_NAME=Adriana James +``` + +4. **Start the backend server** + +```bash +cd backend +python main.py +``` + +The API will be available at http://localhost:8000 + +5. **Serve the frontend** + +You can use any static file server. For development, Python's built-in HTTP server works well: + +```bash +cd frontend +python -m http.server 3000 +``` + +The frontend will be available at http://localhost:3000 + +## 🔧 Usage + +### Generating Content + +1. Navigate to the "Generate" tab in the web interface +2. Enter your content prompt (e.g., "Write a social media post about our new coaching program") +3. Select optional parameters (content type, tone, length) +4. Click "Generate" and wait for the AI to create your content +5. Review, improve, and save the generated content + +### Managing Brand Style + +1. Go to the "Brand Style" tab +2. Configure tone options and voice characteristics +3. Add or remove taboo words +4. Define preferred terminology +5. Save the updated brand style + +### Adding Training Data + +1. Visit the "Training" tab +2. Select the content type +3. Enter your high-performing marketing content +4. Add performance metrics if available +5. Submit the training data to improve the AI over time + +## 📁 Project Structure + +``` +Marketing_Assistant_AI/ +│-- backend/ +│ │-- main.py # FastAPI application +│ │-- copywriter.py # AI content generation +│ │-- vector_store.py # FAISS vector database +│ │-- embeddings.py # Cohere embeddings +│ │-- brand_style.py # Brand style management +│ │-- config.py # Configuration settings +│ │-- requirements.txt # Python dependencies +│ +│-- data/ +│ │-- past_campaigns/ # Stored marketing campaigns +│ │-- user_queries/ # Query history +│ │-- style_guidelines/ # Brand style config +│ │ │-- brand_style.json # Default style guidelines +│ +│-- frontend/ +│ │-- index.html # Main HTML page +│ │-- styles.css # CSS styles +│ │-- app.js # Frontend functionality +│ +│-- docs/ +│ │-- README.md # Developer documentation +│ │-- API_Documentation.md # API reference +│ +│-- .env # Environment variables +│-- .gitignore # Git ignore rules +│-- LICENSE # License information +│-- README.md # This file +``` + +## 🔄 API Endpoints + +The backend provides several RESTful API endpoints: + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/generate-copy` | POST | Generate marketing content | +| `/brand-style` | GET | Retrieve brand style guidelines | +| `/brand-style` | PUT | Update brand style guidelines | +| `/training-data` | POST | Add new training data | +| `/training-data` | GET | List available training data | +| `/training-data/{id}` | GET | Get specific training document | +| `/training-data/{id}` | DELETE | Delete training document | +| `/improve-content` | POST | Improve content based on feedback | +| `/analyze-content` | POST | Analyze content performance | + +For detailed API documentation, see [API Documentation](docs/API_Documentation.md). + +## 💡 How It Works + +1. **Content Generation Process** + - User submits a content request + - System formats the prompt with brand style guidelines + - Similar past content is retrieved from the vector database + - The AI generates content using the formatted prompt and references + - The system checks alignment with brand guidelines + - Final content is returned with alignment score and suggestions + +2. **Brand Style Management** + - Brand style guidelines are stored in JSON format + - The system uses these guidelines to format prompts for the AI + - Content is checked against taboo words and preferred terminology + - An alignment score is calculated based on adherence to guidelines + +3. **Vector Search** + - Marketing content is converted to vector embeddings using Cohere + - FAISS enables efficient similarity search across thousands of documents + - Similar content is reranked based on relevance to the query + +## 🛠️ Development + +### Backend Development + +The backend is built with FastAPI and follows a modular architecture: + +- `main.py`: API endpoints and request handling +- `copywriter.py`: Core AI content generation +- `vector_store.py`: Vector database operations +- `embeddings.py`: Embedding generation and reranking +- `brand_style.py`: Brand style management +- `config.py`: Configuration settings + +### Frontend Development + +The frontend is built with vanilla HTML, CSS, and JavaScript: + +- `index.html`: Main application structure and UI components +- `styles.css`: Styling and responsive design rules +- `app.js`: User interactions and API integration + +## 🔍 Future Enhancements + +- User authentication and role-based access +- Content performance analytics dashboard +- A/B testing capabilities +- Integration with marketing platforms +- Custom model fine-tuning for even better results +- Mobile application + +## 👥 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## 📄 License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## 👏 Acknowledgements + +- [Cohere](https://cohere.ai/) for AI models +- [FastAPI](https://fastapi.tiangolo.com/) for the API framework +- [FAISS](https://github.com/facebookresearch/faiss) for vector search + +--- + +Made with ❤️ for Adriana James \ No newline at end of file diff --git a/backend/brand_style.py b/backend/brand_style.py new file mode 100644 index 0000000..6232d35 --- /dev/null +++ b/backend/brand_style.py @@ -0,0 +1,175 @@ +""" +Brand style module for the Marketing Assistant AI. +Ensures generated content aligns with Adriana James' brand voice and tone. +""" + +import json +import os +from typing import Dict, List, Any, Optional +from pathlib import Path +from loguru import logger + +import config + +class BrandStyleManager: + """Manages brand style guidelines and ensures content consistency.""" + + def __init__(self): + """Initialize the BrandStyleManager with default or stored style guidelines.""" + self.style_path = Path(config.DATA_DIR) / "style_guidelines" / "brand_style.json" + self.style_guidelines = self._load_or_create_style() + logger.info("BrandStyleManager initialized successfully") + + def _load_or_create_style(self) -> Dict[str, Any]: + """Load existing style guidelines or create new ones with defaults.""" + try: + if self.style_path.exists(): + with open(self.style_path, 'r') as f: + style = json.load(f) + logger.info("Loaded existing brand style guidelines") + return style + else: + # Create directory if it doesn't exist + self.style_path.parent.mkdir(exist_ok=True) + + # Use default style guidelines + style = config.DEFAULT_BRAND_STYLE + + # Save default style + with open(self.style_path, 'w') as f: + json.dump(style, f, indent=2) + + logger.info("Created default brand style guidelines") + return style + except Exception as e: + logger.error(f"Error loading or creating style guidelines: {str(e)}") + # Fall back to default style + return config.DEFAULT_BRAND_STYLE + + def get_style_guidelines(self) -> Dict[str, Any]: + """ + Get current brand style guidelines. + + Returns: + Dictionary of style guidelines + """ + return self.style_guidelines + + def update_style_guidelines(self, new_style: Dict[str, Any]) -> Dict[str, Any]: + """ + Update brand style guidelines. + + Args: + new_style: Dictionary with new style guidelines + + Returns: + Updated style guidelines dictionary + """ + try: + # Merge new style with existing + for key, value in new_style.items(): + self.style_guidelines[key] = value + + # Ensure brand name is preserved + self.style_guidelines['brand_name'] = config.BRAND_NAME + + # Save updated style + with open(self.style_path, 'w') as f: + json.dump(self.style_guidelines, f, indent=2) + + logger.info("Updated brand style guidelines") + return self.style_guidelines + except Exception as e: + logger.error(f"Error updating style guidelines: {str(e)}") + raise + + def format_prompt_with_brand_style(self, user_prompt: str, content_type: Optional[str] = None) -> str: + """ + Format user prompt with brand style guidelines for the LLM. + + Args: + user_prompt: Original user prompt + content_type: Type of content being generated + + Returns: + Formatted prompt with brand style instructions + """ + style = self.style_guidelines + + # Create a formatted prompt with brand style instructions + prompt_parts = [ + f"Generate marketing content for {style['brand_name']} based on the following request:", + f"\"{user_prompt}\"", + "\nFollow these brand style guidelines:", + f"- Brand Name: {style['brand_name']}", + f"- Tone: {', '.join(style.get('tone', []))}", + f"- Voice Characteristics: {', '.join(style.get('voice_characteristics', []))}", + ] + + # Add taboo words if any + if 'taboo_words' in style and style['taboo_words']: + prompt_parts.append(f"- Avoid these words: {', '.join(style['taboo_words'])}") + + # Add preferred terms if any + if 'preferred_terms' in style and style['preferred_terms']: + terms = [f"use '{value}' instead of '{key}'" for key, value in style['preferred_terms'].items()] + prompt_parts.append(f"- Preferred terminology: {'; '.join(terms)}") + + # Add content type specific instructions + if content_type: + if content_type == "email_campaign": + prompt_parts.append("- Format as a professional email with subject line, greeting, body, and signature") + elif content_type == "social_media": + prompt_parts.append("- Format as a concise social media post with appropriate hashtags") + elif content_type == "blog_post": + prompt_parts.append("- Format as a blog post with title, introduction, body with subheadings, and conclusion") + elif content_type == "website_copy": + prompt_parts.append("- Format as website copy with clear headings and concise paragraphs") + elif content_type == "ad_copy": + prompt_parts.append("- Format as advertising copy with headline, body, and clear call to action") + + # Combine all parts + formatted_prompt = "\n".join(prompt_parts) + + logger.debug("Created formatted prompt with brand style") + return formatted_prompt + + def check_content_alignment(self, content: str) -> Dict[str, Any]: + """ + Check if generated content aligns with brand style guidelines. + + Args: + content: Generated marketing content + + Returns: + Dictionary with alignment metrics and suggestions + """ + style = self.style_guidelines + taboo_words = style.get('taboo_words', []) + preferred_terms = style.get('preferred_terms', {}) + + # Check for taboo words + found_taboo_words = [] + for word in taboo_words: + if word.lower() in content.lower(): + found_taboo_words.append(word) + + # Check for preferred terminology + terminology_issues = [] + for avoid, use in preferred_terms.items(): + if avoid.lower() in content.lower(): + terminology_issues.append(f"Found '{avoid}', should use '{use}' instead") + + # Calculate an overall alignment score (simple implementation) + issues_count = len(found_taboo_words) + len(terminology_issues) + alignment_score = max(0, 100 - (issues_count * 10)) # Reduce score for each issue + + return { + 'alignment_score': alignment_score, + 'taboo_words_found': found_taboo_words, + 'terminology_issues': terminology_issues, + 'aligned': alignment_score >= 80 # Consider aligned if score is 80% or higher + } + +# Create a singleton instance +brand_style_manager = BrandStyleManager() \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..214da1a --- /dev/null +++ b/backend/config.py @@ -0,0 +1,87 @@ +""" +Configuration module for the Marketing Assistant AI. +Handles environment variables and application settings. +""" + +import os +from pathlib import Path +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +# Base paths +BASE_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = BASE_DIR / "data" + +# Ensure data directories exist +(DATA_DIR / "past_campaigns").mkdir(exist_ok=True) +(DATA_DIR / "user_queries").mkdir(exist_ok=True) +(DATA_DIR / "style_guidelines").mkdir(exist_ok=True) + +# API configuration +API_HOST = os.getenv("API_HOST", "localhost") +API_PORT = int(os.getenv("API_PORT", 8000)) + +# LLM configuration +LLM_MODEL = os.getenv("LLM_MODEL") +LLM_API_KEY = os.getenv("LLM_API_KEY") + +# Cohere configuration +COHERE_API_KEY = os.getenv("COHERE_API_KEY") + +# Vector database configuration +VECTOR_DB_PATH = os.getenv("VECTOR_DB_PATH", str(DATA_DIR / "vector_store")) + +# Brand configuration +BRAND_NAME = os.getenv("BRAND_NAME", "Adriana James") + +# Content types +CONTENT_TYPES = [ + "email_campaign", + "social_media", + "blog_post", + "website_copy", + "ad_copy", + "funnel_page", + "product_description", + "press_release" +] + +# Tone options +TONE_OPTIONS = [ + "professional", + "friendly", + "excited", + "authoritative", + "casual", + "inspirational", + "empathetic", + "humorous" +] + +# Content length options +LENGTH_OPTIONS = [ + "short", # < 100 words + "medium", # 100-300 words + "long", # > 300 words +] + +# Default brand style guidelines +DEFAULT_BRAND_STYLE = { + "brand_name": BRAND_NAME, + "tone": ["professional", "friendly", "inspirational"], + "voice_characteristics": ["clear", "direct", "empowering"], + "taboo_words": ["cheap", "discount", "bargain"], + "preferred_terms": { + "customers": "clients", + "products": "solutions" + } +} + +# Logging configuration +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") +LOG_FILE = os.getenv("LOG_FILE", str(BASE_DIR / "logs" / "app.log")) + +# Create logs directory if it doesn't exist +(BASE_DIR / "logs").mkdir(exist_ok=True) \ No newline at end of file diff --git a/backend/copywriter.py b/backend/copywriter.py new file mode 100644 index 0000000..ede1bbc --- /dev/null +++ b/backend/copywriter.py @@ -0,0 +1,278 @@ +""" +Copywriter module for the Marketing Assistant AI. +Core AI-powered content generation using a fine-tuned LLM. +""" + +import os +import json +import httpx +from typing import Dict, List, Any, Optional, Tuple +from loguru import logger +from tenacity import retry, stop_after_attempt, wait_exponential + +import config +from brand_style import brand_style_manager +from vector_store import vector_store + +class Copywriter: + """Generates marketing copy using a fine-tuned LLM.""" + + def __init__(self): + """Initialize the Copywriter with Cohere LLM client.""" + self.model = "command" # Cohere's generation model + self.api_key = config.COHERE_API_KEY + logger.info("Copywriter initialized with Cohere API successfully") + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10)) + async def generate_copy( + self, + prompt: str, + content_type: Optional[str] = None, + tone: Optional[str] = None, + length: Optional[str] = None, + include_cta: bool = False, + reference_similar_content: bool = True, + max_tokens: int = 1000 + ) -> Dict[str, Any]: + """ + Generate marketing copy based on the user prompt and parameters. + + Args: + prompt: User prompt for content generation + content_type: Type of content to generate + tone: Desired tone of the content + length: Desired length of the content + include_cta: Whether to include a call to action + reference_similar_content: Whether to fetch and reference similar content + max_tokens: Maximum tokens for the generated response + + Returns: + Dictionary with generated content and metadata + """ + try: + # Step 1: Format prompt with brand style guidelines + branded_prompt = brand_style_manager.format_prompt_with_brand_style(prompt, content_type) + + # Step 2: Find similar content for reference (if enabled) + reference_content = [] + if reference_similar_content: + search_results = await vector_store.search(prompt, top_k=3) + if search_results: + reference_content = [result['text'] for result in search_results] + + # Step 3: Add additional instructions based on parameters + full_prompt = branded_prompt + + if tone: + full_prompt += f"\n- Use a {tone} tone" + + if length: + length_instructions = { + "short": "Keep the content brief and to the point (under 100 words).", + "medium": "Write a moderate amount of content (100-300 words).", + "long": "Create comprehensive content with depth (over 300 words)." + } + full_prompt += f"\n- {length_instructions.get(length, '')}" + + if include_cta: + full_prompt += "\n- Include a strong call to action at the end" + + # Step 4: Add reference content if available + if reference_content: + full_prompt += "\n\nFor reference, here are some similar pieces of content that have performed well in the past:" + for i, content in enumerate(reference_content, 1): + # Truncate reference content if it's too long + preview = content[:300] + "..." if len(content) > 300 else content + full_prompt += f"\n\nReference {i}:\n{preview}" + + full_prompt += "\n\nUse these references for inspiration, but create original content." + + # Step 5: Generate content using the LLM + generated_content = await self._call_llm_api(full_prompt, max_tokens) + + # Step 6: Check content alignment with brand style + alignment_check = brand_style_manager.check_content_alignment(generated_content) + + # Step 7: Generate alternative headline suggestions + headline_suggestions = await self._generate_headline_suggestions(prompt, generated_content) + + # Step 8: Return the generated content with metadata + result = { + "content": generated_content, + "suggestions": headline_suggestions, + "metadata": { + "content_type": content_type, + "tone": tone, + "alignment_score": alignment_check['alignment_score'], + "generated_at": None # Will be added by the API + } + } + + # Add alignment issues if any + if alignment_check['taboo_words_found'] or alignment_check['terminology_issues']: + result["alignment_issues"] = { + "taboo_words_found": alignment_check['taboo_words_found'], + "terminology_issues": alignment_check['terminology_issues'] + } + + logger.info(f"Generated content with {len(generated_content)} characters") + return result + + except Exception as e: + logger.error(f"Error generating copy: {str(e)}") + raise + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10)) + async def _call_llm_api(self, prompt: str, max_tokens: int = 1000) -> str: + """ + Call the Cohere API to generate content. + + Args: + prompt: The formatted prompt for the LLM + max_tokens: Maximum tokens for the generated response + + Returns: + Generated content as a string + """ + try: + # Use Cohere's generate API with the API key from config + cohere_api_key = config.COHERE_API_KEY + + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.cohere.ai/v1/generate", + headers={ + "Authorization": f"Bearer {cohere_api_key}", + "Content-Type": "application/json" + }, + json={ + "model": "command", # Cohere's generation model + "prompt": prompt, + "max_tokens": max_tokens, + "temperature": 0.7, + "k": 0, + "p": 0.75 + }, + timeout=30.0 + ) + + if response.status_code == 200: + result = response.json() + return result["generations"][0]["text"].strip() + else: + logger.error(f"Cohere API error: {response.status_code}, {response.text}") + raise Exception(f"Cohere API error: {response.status_code}") + + except Exception as e: + logger.error(f"Error calling Cohere API: {str(e)}") + raise + + async def _generate_headline_suggestions(self, original_prompt: str, generated_content: str) -> List[str]: + """ + Generate alternative headline suggestions based on the content. + + Args: + original_prompt: The original user prompt + generated_content: The generated marketing content + + Returns: + List of headline suggestions + """ + try: + # This would call the LLM to generate headlines + # Simplified mock response for demonstration + return [ + "Alternative Headline 1: Discover the Power of Adriana James' Solutions", + "Alternative Headline 2: Transform Your Results with Adriana James", + "Alternative Headline 3: The Adriana James Approach: Excellence Redefined" + ] + except Exception as e: + logger.error(f"Error generating headline suggestions: {str(e)}") + return [] + + async def improve_copy(self, content: str, feedback: str) -> str: + """ + Improve content based on user feedback. + + Args: + content: Original generated content + feedback: User feedback for improvement + + Returns: + Improved content + """ + try: + # Format prompt for improvement + improve_prompt = f""" + Please improve the following marketing content based on the feedback provided: + + ORIGINAL CONTENT: + {content} + + FEEDBACK: + {feedback} + + IMPROVED CONTENT: + """ + + # Call LLM to improve content + improved_content = await self._call_llm_api(improve_prompt, max_tokens=1200) + + logger.info(f"Improved content based on feedback") + return improved_content + + except Exception as e: + logger.error(f"Error improving content: {str(e)}") + raise + + async def analyze_content_performance(self, content: str) -> Dict[str, Any]: + """ + Analyze marketing content for performance prediction. + + Args: + content: Marketing content to analyze + + Returns: + Dictionary with analysis results + """ + try: + # This would be enhanced with actual ML models in production + # Simplified mock response for demonstration + + # Very basic analysis using length and keyword presence + word_count = len(content.split()) + has_cta = any(phrase in content.lower() for phrase in ["call", "contact", "get started", "try", "buy", "sign up"]) + sentence_count = len([s for s in content.split(".") if s.strip()]) + avg_words_per_sentence = word_count / max(1, sentence_count) + + # Simple scoring system + readability_score = 100 - min(100, max(0, abs(avg_words_per_sentence - 15) * 5)) + cta_score = 90 if has_cta else 60 + length_score = min(100, max(0, word_count / 3)) + + overall_score = (readability_score + cta_score + length_score) / 3 + + return { + "overall_score": round(overall_score, 1), + "readability_score": round(readability_score, 1), + "cta_effectiveness": round(cta_score, 1), + "length_appropriateness": round(length_score, 1), + "metrics": { + "word_count": word_count, + "sentence_count": sentence_count, + "avg_words_per_sentence": round(avg_words_per_sentence, 1), + "has_cta": has_cta + }, + "improvement_suggestions": [ + "Consider adding a stronger call to action" if cta_score < 80 else "Your call to action is effective", + "Try to use shorter sentences for better readability" if avg_words_per_sentence > 20 else "Your sentence length is good for readability", + "Consider adding more content for better engagement" if word_count < 100 else "Your content length is appropriate" + ] + } + + except Exception as e: + logger.error(f"Error analyzing content: {str(e)}") + raise + +# Create a singleton instance +copywriter = Copywriter() \ No newline at end of file diff --git a/backend/data/vector_store/faiss_index.bin b/backend/data/vector_store/faiss_index.bin new file mode 100644 index 0000000000000000000000000000000000000000..969a818c72187639fb44717105f99de43e665fdc GIT binary patch literal 45101 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 literal 0 HcmV?d00001 diff --git a/backend/data/vector_store/metadata.pkl b/backend/data/vector_store/metadata.pkl new file mode 100644 index 0000000000000000000000000000000000000000..6d6ae0866e48c7a79fa0ac26c86830b68d590742 GIT binary patch literal 12686 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 np.ndarray: + """ + Generate embeddings for a list of texts. + + Args: + texts: List of text strings to embed + model: Cohere embedding model to use + + Returns: + numpy.ndarray: Array of embeddings vectors + """ + try: + if not texts: + logger.warning("Empty text list provided for embedding") + return np.array([]) + + # Ensure texts are not too long for the API + processed_texts = [text[:8192] for text in texts] + + response = self.co.embed( + texts=processed_texts, + model=model, + input_type="search_document" + ) + + embeddings = np.array(response.embeddings) + logger.debug(f"Generated {len(embeddings)} embeddings with shape {embeddings.shape}") + return embeddings + + except Exception as e: + logger.error(f"Error generating embeddings: {str(e)}") + raise + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10)) + async def get_query_embedding(self, text: str, model: str = "embed-english-v3.0") -> np.ndarray: + """ + Generate embedding for a single query text. + + Args: + text: The query text to embed + model: Cohere embedding model to use + + Returns: + numpy.ndarray: Embedding vector for the query + """ + try: + response = self.co.embed( + texts=[text[:8192]], + model=model, + input_type="search_query" + ) + + embedding = np.array(response.embeddings[0]) + return embedding + + except Exception as e: + logger.error(f"Error generating query embedding: {str(e)}") + raise + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10)) + async def rerank_results( + self, + query: str, + documents: List[str], + model: str = "rerank-english-v2.0", + top_n: int = 5 + ) -> List[Dict[str, Any]]: + """ + Rerank documents based on relevance to the query. + + Args: + query: The search query + documents: List of documents to rerank + model: Cohere reranking model to use + top_n: Number of top results to return + + Returns: + List of dictionaries with document index and relevance score + """ + try: + if not documents: + logger.warning("Empty document list provided for reranking") + return [] + + # Truncate documents if they're too long + processed_docs = [doc[:8192] for doc in documents] + + response = self.co.rerank( + query=query, + documents=processed_docs, + model=model, + top_n=min(top_n, len(processed_docs)) + ) + + results = [ + { + "index": result.index, + "document": documents[result.index], + "relevance_score": result.relevance_score + } + for result in response.results + ] + + logger.debug(f"Reranked {len(documents)} documents, returning top {len(results)}") + return results + + except Exception as e: + logger.error(f"Error reranking documents: {str(e)}") + raise + +# Create a singleton instance +embeddings_manager = EmbeddingsManager() \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..673aca0 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,406 @@ +""" +Main FastAPI application for the Marketing Assistant AI. +Provides API endpoints for generating and managing marketing content. +""" + +import os +import json +from typing import Dict, List, Any, Optional +from datetime import datetime +from pathlib import Path +from fastapi import FastAPI, HTTPException, Depends, Query, Body, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from loguru import logger +from pydantic import BaseModel, Field + +import config +from copywriter import copywriter +from vector_store import vector_store +from brand_style import brand_style_manager +from embeddings import embeddings_manager + +# Initialize logging +logger.add(config.LOG_FILE, level=config.LOG_LEVEL, rotation="10 MB", retention="1 month") + +# Create FastAPI app +app = FastAPI( + title="Marketing Assistant AI", + description="AI-powered tool for marketing copywriting with Adriana James' brand voice", + version="1.0.0" +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify your frontend domain + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Define request and response models +class GenerateCopyRequest(BaseModel): + prompt: str = Field(..., description="The main instruction for generating content") + content_type: Optional[str] = Field(None, description="Type of content to generate") + tone: Optional[str] = Field(None, description="Desired tone of the content") + length: Optional[str] = Field(None, description="Desired length of the content") + include_cta: Optional[bool] = Field(False, description="Whether to include a call to action") + reference_similar_content: Optional[bool] = Field(True, description="Whether to reference similar content") + max_tokens: Optional[int] = Field(1000, description="Maximum tokens for the generated response") + +class TrainingDataRequest(BaseModel): + content_type: str = Field(..., description="Type of content") + content: str = Field(..., description="The marketing content") + metadata: Optional[Dict[str, Any]] = Field({}, description="Additional metadata about the content") + +class BrandStyleUpdateRequest(BaseModel): + tone: Optional[List[str]] = Field(None, description="Brand tone options") + voice_characteristics: Optional[List[str]] = Field(None, description="Voice characteristics") + taboo_words: Optional[List[str]] = Field(None, description="Words to avoid") + preferred_terms: Optional[Dict[str, str]] = Field(None, description="Preferred terminology") + +class ContentImprovementRequest(BaseModel): + content: str = Field(..., description="Original generated content") + feedback: str = Field(..., description="User feedback for improvement") + +# API Routes +@app.get("/") +async def root(): + """Root endpoint with API information.""" + return { + "name": "Marketing Assistant AI", + "version": "1.0.0", + "description": f"AI-powered marketing copywriter for {config.BRAND_NAME}" + } + +@app.post("/generate-copy") +async def generate_copy(request: GenerateCopyRequest): + """Generate marketing copy based on the provided prompt and parameters.""" + try: + # Validate content type if provided + if request.content_type and request.content_type not in config.CONTENT_TYPES: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "status": "error", + "message": f"Invalid content_type. Must be one of: {', '.join(config.CONTENT_TYPES)}" + } + ) + + # Validate tone if provided + if request.tone and request.tone not in config.TONE_OPTIONS: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "status": "error", + "message": f"Invalid tone. Must be one of: {', '.join(config.TONE_OPTIONS)}" + } + ) + + # Validate length if provided + if request.length and request.length not in config.LENGTH_OPTIONS: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "status": "error", + "message": f"Invalid length. Must be one of: {', '.join(config.LENGTH_OPTIONS)}" + } + ) + + # Generate copy + result = await copywriter.generate_copy( + prompt=request.prompt, + content_type=request.content_type, + tone=request.tone, + length=request.length, + include_cta=request.include_cta, + reference_similar_content=request.reference_similar_content, + max_tokens=request.max_tokens + ) + + # Add timestamp + result["metadata"]["generated_at"] = datetime.now().isoformat() + + # Store the generated content in the vector store for future reference + if result["content"]: + metadata = { + "content_type": request.content_type, + "tone": request.tone, + "prompt": request.prompt, + "generated": True + } + await vector_store.add_documents([result["content"]], [metadata]) + + # Store the user query for future training + query_path = Path(config.DATA_DIR) / "user_queries" / f"{datetime.now().strftime('%Y%m%d%H%M%S')}.json" + with open(query_path, 'w') as f: + json.dump({ + "prompt": request.prompt, + "parameters": { + "content_type": request.content_type, + "tone": request.tone, + "length": request.length, + "include_cta": request.include_cta + }, + "timestamp": datetime.now().isoformat() + }, f, indent=2) + + return { + "status": "success", + "content": result["content"], + "suggestions": result.get("suggestions", []), + "metadata": result["metadata"] + } + + except Exception as e: + logger.error(f"Error generating copy: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to generate copy: {str(e)}" + ) + +@app.get("/brand-style") +async def get_brand_style(): + """Get the current brand style guidelines.""" + try: + style = brand_style_manager.get_style_guidelines() + return style + except Exception as e: + logger.error(f"Error getting brand style: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get brand style: {str(e)}" + ) + +@app.put("/brand-style") +async def update_brand_style(request: BrandStyleUpdateRequest): + """Update the brand style guidelines.""" + try: + update_data = request.dict(exclude_unset=True) + updated_style = brand_style_manager.update_style_guidelines(update_data) + + return { + "status": "success", + "message": "Brand style updated successfully", + "style": updated_style + } + except Exception as e: + logger.error(f"Error updating brand style: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update brand style: {str(e)}" + ) + +@app.post("/training-data") +async def add_training_data(request: TrainingDataRequest): + """Add new marketing content for AI training.""" + try: + # Validate content type + if request.content_type not in config.CONTENT_TYPES: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "status": "error", + "message": f"Invalid content_type. Must be one of: {', '.join(config.CONTENT_TYPES)}" + } + ) + + # Add metadata + metadata = request.metadata.copy() + metadata["content_type"] = request.content_type + metadata["added_at"] = datetime.now().isoformat() + metadata["training_data"] = True + + # Add to vector store + doc_ids = await vector_store.add_documents([request.content], [metadata]) + + # Save to past campaigns + campaign_path = Path(config.DATA_DIR) / "past_campaigns" / f"{datetime.now().strftime('%Y%m%d%H%M%S')}.json" + with open(campaign_path, 'w') as f: + json.dump({ + "content": request.content, + "content_type": request.content_type, + "metadata": metadata, + "document_id": doc_ids[0] if doc_ids else None, + "timestamp": datetime.now().isoformat() + }, f, indent=2) + + return { + "status": "success", + "message": "Training data added successfully", + "data_id": doc_ids[0] if doc_ids else None + } + except Exception as e: + logger.error(f"Error adding training data: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to add training data: {str(e)}" + ) + +@app.get("/training-data") +async def list_training_data( + content_type: Optional[str] = Query(None, description="Filter by content type"), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(10, ge=1, le=100, description="Items per page") +): + """Retrieve a list of available training data.""" + try: + # Build filters + filters = {} + if content_type: + if content_type not in config.CONTENT_TYPES: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "status": "error", + "message": f"Invalid content_type. Must be one of: {', '.join(config.CONTENT_TYPES)}" + } + ) + filters["content_type"] = content_type + + filters["training_data"] = True + + # Fetch all matching documents first (not efficient for large datasets but works for demo) + all_docs = [] + for i in range(len(vector_store.metadata)): + doc = await vector_store.get_document(i) + if doc and all(doc["metadata"].get(k) == v for k, v in filters.items()): + all_docs.append(doc) + + # Sort by timestamp (newest first) + all_docs.sort(key=lambda x: x["metadata"].get("added_at", ""), reverse=True) + + # Paginate + total = len(all_docs) + pages = (total + limit - 1) // limit if total > 0 else 1 + start = (page - 1) * limit + end = start + limit + paginated_docs = all_docs[start:end] + + # Format the response + items = [] + for doc in paginated_docs: + # Get a preview of the text (first 100 characters) + preview = doc["text"][:100] + "..." if len(doc["text"]) > 100 else doc["text"] + + items.append({ + "id": doc["document_id"], + "content_type": doc["metadata"].get("content_type", "unknown"), + "preview": preview, + "added_at": doc["metadata"].get("added_at", "") + }) + + return { + "items": items, + "pagination": { + "total": total, + "page": page, + "limit": limit, + "pages": pages + } + } + except Exception as e: + logger.error(f"Error listing training data: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list training data: {str(e)}" + ) + +@app.get("/training-data/{document_id}") +async def get_training_data(document_id: int): + """Retrieve a specific training document by ID.""" + try: + doc = await vector_store.get_document(document_id) + if not doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Document with ID {document_id} not found" + ) + + return { + "id": doc["document_id"], + "content": doc["text"], + "metadata": doc["metadata"] + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error retrieving training data: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve training data: {str(e)}" + ) + +@app.delete("/training-data/{document_id}") +async def delete_training_data(document_id: int): + """Delete a specific training document by ID.""" + try: + success = await vector_store.delete_document(document_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Document with ID {document_id} not found or could not be deleted" + ) + + return { + "status": "success", + "message": f"Document with ID {document_id} successfully deleted" + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting training data: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete training data: {str(e)}" + ) + +@app.post("/improve-content") +async def improve_content(request: ContentImprovementRequest): + """Improve content based on user feedback.""" + try: + improved_content = await copywriter.improve_copy( + content=request.content, + feedback=request.feedback + ) + + return { + "status": "success", + "original_content": request.content, + "improved_content": improved_content, + "feedback": request.feedback + } + except Exception as e: + logger.error(f"Error improving content: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to improve content: {str(e)}" + ) + +@app.post("/analyze-content") +async def analyze_content(content: str = Body(..., embed=True)): + """Analyze marketing content for performance prediction.""" + try: + analysis = await copywriter.analyze_content_performance(content) + + return { + "status": "success", + "analysis": analysis + } + except Exception as e: + logger.error(f"Error analyzing content: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to analyze content: {str(e)}" + ) + +# Run the application +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "main:app", + host=config.API_HOST, + port=config.API_PORT, + reload=True + ) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..37aa443 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,15 @@ +fastapi +uvicorn +pydantic +python-dotenv +httpx +faiss-cpu +numpy==1.26.2 +pandas +cohere +python-multipart +SQLAlchemy +databases +aiosqlite +loguru +tenacity \ No newline at end of file diff --git a/backend/vector_store.py b/backend/vector_store.py new file mode 100644 index 0000000..7ab68d9 --- /dev/null +++ b/backend/vector_store.py @@ -0,0 +1,347 @@ +""" +Vector store module for the Marketing Assistant AI. +Uses FAISS for efficient storage and retrieval of content embeddings. +""" + +import os +import json +import pickle +import faiss +import numpy as np +from typing import List, Dict, Any, Optional, Tuple +from pathlib import Path +from loguru import logger +from datetime import datetime + +import config +from embeddings import embeddings_manager + +class VectorStore: + """Manages vector database operations for content retrieval.""" + + def __init__(self): + """Initialize the VectorStore with FAISS index.""" + self.store_path = Path(config.VECTOR_DB_PATH) + self.store_path.mkdir(exist_ok=True) + + self.index_path = self.store_path / "faiss_index.bin" + self.metadata_path = self.store_path / "metadata.pkl" + + self.dimension = None + self.index = None + self.metadata = [] + + self._load_or_create_index() + logger.info("VectorStore initialized successfully") + + def _load_or_create_index(self) -> None: + """Load existing index or create new one if it doesn't exist.""" + try: + if self.index_path.exists() and self.metadata_path.exists(): + # Load existing index and metadata + self.index = faiss.read_index(str(self.index_path)) + with open(self.metadata_path, 'rb') as f: + self.metadata = pickle.load(f) + self.dimension = self.index.d + logger.info(f"Loaded existing vector index with {self.index.ntotal} vectors") + else: + # Default dimension for Cohere embeddings + self.dimension = 1024 + self.index = faiss.IndexFlatL2(self.dimension) + self.metadata = [] + logger.info(f"Created new vector index with dimension {self.dimension}") + + # Save the empty index and metadata + self._save_index() + except Exception as e: + logger.error(f"Error loading or creating index: {str(e)}") + raise + + def _save_index(self) -> None: + """Save the index and metadata to disk.""" + try: + faiss.write_index(self.index, str(self.index_path)) + with open(self.metadata_path, 'wb') as f: + pickle.dump(self.metadata, f) + logger.debug("Saved vector index and metadata") + except Exception as e: + logger.error(f"Error saving index: {str(e)}") + raise + + async def add_documents( + self, + texts: List[str], + metadata_list: Optional[List[Dict[str, Any]]] = None + ) -> List[int]: + """ + Add documents to the vector store. + + Args: + texts: List of text documents to add + metadata_list: List of metadata dictionaries for each document + + Returns: + List of document IDs (vector indices) + """ + try: + if not texts: + logger.warning("No texts provided to add to vector store") + return [] + + if metadata_list is None: + metadata_list = [{} for _ in texts] + + if len(texts) != len(metadata_list): + raise ValueError("Number of texts and metadata entries must match") + + # Generate embeddings + embeddings = await embeddings_manager.get_embeddings(texts) + + # Check if embeddings match our dimension + if embeddings.shape[1] != self.dimension: + logger.warning(f"Embedding dimension mismatch: expected {self.dimension}, got {embeddings.shape[1]}") + # If we have no documents yet, we can adapt to the new dimension + if self.index.ntotal == 0: + self.dimension = embeddings.shape[1] + self.index = faiss.IndexFlatL2(self.dimension) + logger.info(f"Adapted to new dimension: {self.dimension}") + else: + raise ValueError(f"Embedding dimension mismatch: expected {self.dimension}, got {embeddings.shape[1]}") + + # Add timestamp to metadata + timestamp = datetime.now().isoformat() + for meta in metadata_list: + meta['timestamp'] = timestamp + meta['document_id'] = len(self.metadata) + len(metadata_list) + + # Store texts in metadata + for i, (text, meta) in enumerate(zip(texts, metadata_list)): + meta['text'] = text + + # Add vectors to index + start_idx = self.index.ntotal + self.index.add(embeddings.astype(np.float32)) + self.metadata.extend(metadata_list) + + # Save updated index + self._save_index() + + # Return document IDs + doc_ids = list(range(start_idx, start_idx + len(texts))) + logger.info(f"Added {len(texts)} documents to vector store") + return doc_ids + + except Exception as e: + logger.error(f"Error adding documents to vector store: {str(e)}") + raise + + async def search( + self, + query: str, + top_k: int = 5, + filters: Optional[Dict[str, Any]] = None, + rerank: bool = True + ) -> List[Dict[str, Any]]: + """ + Search for similar documents. + + Args: + query: The search query + top_k: Number of results to return + filters: Dictionary of metadata filters + rerank: Whether to use Cohere's reranking + + Returns: + List of result dictionaries with document content and metadata + """ + try: + if self.index.ntotal == 0: + logger.warning("Empty vector store, no results to return") + return [] + + # Generate query embedding + query_embedding = await embeddings_manager.get_query_embedding(query) + query_embedding = query_embedding.reshape(1, -1).astype(np.float32) + + # First pass: find more candidates than needed for reranking + search_k = top_k * 3 if rerank else top_k + search_k = min(search_k, self.index.ntotal) # Don't request more than we have + + distances, indices = self.index.search(query_embedding, search_k) + + # Get metadata and texts for matching indices + results = [] + for i, idx in enumerate(indices[0]): + if idx < 0 or idx >= len(self.metadata): + continue # Skip invalid indices + + metadata = self.metadata[idx] + text = metadata.get('text', '') + + # Apply filters if any + if filters and not self._matches_filters(metadata, filters): + continue + + results.append({ + 'document_id': idx, + 'text': text, + 'metadata': {k: v for k, v in metadata.items() if k != 'text'}, + 'distance': float(distances[0][i]) + }) + + # Apply reranking if requested + if rerank and results: + texts = [r['text'] for r in results] + reranked = await embeddings_manager.rerank_results(query, texts, top_n=top_k) + + # Map reranked results back to our original results + reranked_results = [] + for item in reranked: + orig_idx = item['index'] + if 0 <= orig_idx < len(results): + reranked_results.append({ + **results[orig_idx], + 'relevance_score': item['relevance_score'] + }) + + results = reranked_results + else: + # Just take the top_k results + results = results[:top_k] + + logger.info(f"Found {len(results)} matching documents for query") + return results + + except Exception as e: + logger.error(f"Error searching vector store: {str(e)}") + raise + + def _matches_filters(self, metadata: Dict[str, Any], filters: Dict[str, Any]) -> bool: + """Check if metadata matches the specified filters.""" + for key, value in filters.items(): + if key not in metadata: + return False + + if isinstance(value, list): + # Check if metadata value is in the list + if metadata[key] not in value: + return False + elif metadata[key] != value: + return False + + return True + + async def delete_document(self, document_id: int) -> bool: + """ + Delete a document from the vector store. + + Args: + document_id: ID of the document to delete + + Returns: + Boolean indicating success + """ + try: + if document_id < 0 or document_id >= len(self.metadata): + logger.warning(f"Invalid document ID: {document_id}") + return False + + # FAISS doesn't support direct deletion, so we need to rebuild the index + # Mark the document as deleted in metadata + self.metadata[document_id]['deleted'] = True + + # Save updated metadata + self._save_index() + + logger.info(f"Marked document {document_id} as deleted") + return True + + except Exception as e: + logger.error(f"Error deleting document: {str(e)}") + raise + + async def get_document(self, document_id: int) -> Optional[Dict[str, Any]]: + """ + Retrieve a document by ID. + + Args: + document_id: ID of the document to retrieve + + Returns: + Document with metadata or None if not found + """ + try: + if document_id < 0 or document_id >= len(self.metadata): + logger.warning(f"Invalid document ID: {document_id}") + return None + + metadata = self.metadata[document_id] + + # Check if document is marked as deleted + if metadata.get('deleted', False): + logger.warning(f"Document {document_id} is marked as deleted") + return None + + text = metadata.get('text', '') + + return { + 'document_id': document_id, + 'text': text, + 'metadata': {k: v for k, v in metadata.items() if k != 'text' and k != 'deleted'} + } + + except Exception as e: + logger.error(f"Error retrieving document: {str(e)}") + raise + + async def update_document(self, document_id: int, text: str, metadata: Optional[Dict[str, Any]] = None) -> bool: + """ + Update a document in the vector store. + + Args: + document_id: ID of the document to update + text: New document text + metadata: New metadata (will be merged with existing) + + Returns: + Boolean indicating success + """ + try: + if document_id < 0 or document_id >= len(self.metadata): + logger.warning(f"Invalid document ID: {document_id}") + return False + + # Get existing metadata + existing_metadata = self.metadata[document_id] + + # Check if document is marked as deleted + if existing_metadata.get('deleted', False): + logger.warning(f"Cannot update deleted document {document_id}") + return False + + # Generate new embedding + embeddings = await embeddings_manager.get_embeddings([text]) + + # Update the vector in the index + faiss.IndexFlatL2_update_vectors(self.index, embeddings.astype(np.float32), np.array([document_id], dtype=np.int64)) + + # Update metadata + if metadata: + for key, value in metadata.items(): + existing_metadata[key] = value + + existing_metadata['text'] = text + existing_metadata['updated_at'] = datetime.now().isoformat() + + # Save updated index + self._save_index() + + logger.info(f"Updated document {document_id}") + return True + + except Exception as e: + logger.error(f"Error updating document: {str(e)}") + raise + +# Create a singleton instance +vector_store = VectorStore() \ No newline at end of file diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..4b8bea4 --- /dev/null +++ b/data/README.md @@ -0,0 +1,81 @@ +# Marketing Assistant AI - Data Directory + +This directory contains the data used by the Marketing Assistant AI system. + +## Structure + +- **past_campaigns/**: Contains JSON files of past marketing campaigns used for training and reference +- **user_queries/**: Stores user queries and requests for analytics and model improvement +- **style_guidelines/**: Contains brand tone and voice guidelines +- **vector_store/**: Generated vector database for content retrieval (created automatically) + +## File Formats + +### Past Campaigns + +Past campaign files are stored as JSON with the following structure: + +```json +{ + "content": "The actual marketing content text", + "content_type": "email_campaign|social_media|blog_post|etc", + "metadata": { + "campaign_name": "Name of the campaign", + "performance_metrics": { + "metric1": value, + "metric2": value + }, + "content_type": "Same as above", + "added_at": "ISO timestamp", + "training_data": true + }, + "document_id": 0, + "timestamp": "ISO timestamp" +} +``` + +### User Queries + +User query files store information about requests made to the AI: + +```json +{ + "prompt": "The user's prompt text", + "parameters": { + "content_type": "Type of content requested", + "tone": "Requested tone", + "length": "Requested length", + "include_cta": true|false + }, + "timestamp": "ISO timestamp" +} +``` + +### Brand Style Guidelines + +Brand style is stored as a JSON file with the following structure: + +```json +{ + "brand_name": "Adriana James", + "tone": ["professional", "friendly", "inspirational"], + "voice_characteristics": ["clear", "direct", "empowering"], + "taboo_words": ["cheap", "discount", "bargain"], + "preferred_terms": { + "customers": "clients", + "products": "solutions" + } +} +``` + +## Adding New Data + +### Adding Past Campaigns + +1. Use the API endpoint `POST /training-data` with the appropriate JSON payload +2. Alternatively, add a JSON file to the `past_campaigns` directory following the format above + +### Updating Brand Style + +1. Use the API endpoint `PUT /brand-style` with the updated style guidelines +2. The system will automatically update the style file \ No newline at end of file diff --git a/data/past_campaigns/.gitkeep b/data/past_campaigns/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/style_guidelines/brand_style.json b/data/style_guidelines/brand_style.json new file mode 100644 index 0000000..43e5c62 --- /dev/null +++ b/data/style_guidelines/brand_style.json @@ -0,0 +1,36 @@ +{ + "brand_name": "Adriana James", + "tone": [ + "professional", + "friendly", + "inspirational", + "empowering", + "excited", + "authoritative" + ], + "voice_characteristics": [ + "clear", + "direct", + "empowering", + "confident", + "authentic", + "innovative", + "visionary", + "approachable" + ], + "taboo_words": [ + "cheap", + "discount", + "bargain", + "failure", + "impossible", + "difficult" + ], + "preferred_terms": { + "customers": "clients", + "products": "solutions", + "problems": "challenges", + "services": "experiences", + "training": "transformation" + } +} \ No newline at end of file diff --git a/data/user_queries/.gitkeep b/data/user_queries/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/API_DOCUMENTATION.md b/docs/API_DOCUMENTATION.md new file mode 100644 index 0000000..e9dbdf1 --- /dev/null +++ b/docs/API_DOCUMENTATION.md @@ -0,0 +1,187 @@ +# Marketing Assistant AI - API Documentation + +## API Endpoints + +### Generate Copy + +Generates marketing copy based on the provided prompt and optional parameters. + +**Endpoint**: `/generate-copy` +**Method**: POST +**Content-Type**: application/json + +**Request Body**: +```json +{ + "prompt": "Write a social media post for our new product launch", + "content_type": "social_media", + "tone": "excited", + "length": "medium", + "include_cta": true +} +``` + +**Parameters**: +- `prompt` (string, required): The main instruction for generating content +- `content_type` (string, optional): Type of content to generate (social_media, email, blog, website, etc.) +- `tone` (string, optional): Desired tone (excited, professional, casual, etc.) +- `length` (string, optional): Content length (short, medium, long) +- `include_cta` (boolean, optional): Whether to include a call to action + +**Response**: +```json +{ + "status": "success", + "content": "Exciting news! Our revolutionary new product has just landed...", + "suggestions": [ + "Alternative headline option 1", + "Alternative headline option 2" + ], + "metadata": { + "content_type": "social_media", + "tone": "excited", + "word_count": 85, + "generated_at": "2025-04-17T10:30:45Z" + } +} +``` + +### Get Brand Style Guidelines + +Retrieves the current brand style guidelines. + +**Endpoint**: `/brand-style` +**Method**: GET + +**Response**: +```json +{ + "brand_name": "Adriana James", + "tone": ["professional", "friendly", "inspiring"], + "voice_characteristics": ["clear", "direct", "empowering"], + "taboo_words": ["cheap", "discount", "bargain"], + "preferred_terms": { + "customers": "clients", + "products": "solutions" + } +} +``` + +### Update Brand Style + +Updates the brand style guidelines. + +**Endpoint**: `/brand-style` +**Method**: PUT +**Content-Type**: application/json + +**Request Body**: +```json +{ + "tone": ["professional", "friendly", "inspiring", "innovative"], + "voice_characteristics": ["clear", "direct", "empowering"], + "taboo_words": ["cheap", "discount", "bargain", "basic"], + "preferred_terms": { + "customers": "clients", + "products": "solutions", + "problems": "challenges" + } +} +``` + +**Response**: +```json +{ + "status": "success", + "message": "Brand style updated successfully" +} +``` + +### Add Training Data + +Adds new marketing content for AI training. + +**Endpoint**: `/training-data` +**Method**: POST +**Content-Type**: application/json + +**Request Body**: +```json +{ + "content_type": "email_campaign", + "content": "Dear valued client, We're thrilled to announce...", + "metadata": { + "campaign_name": "Spring Launch 2025", + "performance_metrics": { + "open_rate": 0.42, + "click_rate": 0.15 + } + } +} +``` + +**Response**: +```json +{ + "status": "success", + "message": "Training data added successfully", + "data_id": "12345" +} +``` + +### List Training Data + +Retrieves a list of available training data. + +**Endpoint**: `/training-data` +**Method**: GET + +**Query Parameters**: +- `content_type` (optional): Filter by content type +- `page` (optional): Page number for pagination +- `limit` (optional): Number of items per page + +**Response**: +```json +{ + "items": [ + { + "id": "12345", + "content_type": "email_campaign", + "preview": "Dear valued client, We're thrilled to announce...", + "added_at": "2025-04-10T14:30:00Z" + }, + { + "id": "12346", + "content_type": "social_media", + "preview": "Exciting news! Our revolutionary new product...", + "added_at": "2025-04-11T09:15:00Z" + } + ], + "pagination": { + "total": 45, + "page": 1, + "limit": 10, + "pages": 5 + } +} +``` + +## Error Handling + +All endpoints return standard HTTP status codes: + +- `200 OK`: Request successful +- `400 Bad Request`: Invalid request parameters +- `401 Unauthorized`: Authentication failed +- `404 Not Found`: Resource not found +- `500 Internal Server Error`: Server-side error + +Error response format: +```json +{ + "status": "error", + "message": "Detailed error message", + "error_code": "ERROR_CODE" +} +``` \ No newline at end of file diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..2d8bf8c --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,513 @@ +// DOM Elements +document.addEventListener('DOMContentLoaded', function() { + // Navigation + const menuItems = document.querySelectorAll('.menu li'); + const pages = document.querySelectorAll('.page'); + + // Generate Content Page + const generateBtn = document.getElementById('generate-btn'); + const promptInput = document.getElementById('prompt'); + const contentTypeSelect = document.getElementById('content-type'); + const toneSelect = document.getElementById('tone'); + const lengthSelect = document.getElementById('length'); + const includeCTACheckbox = document.getElementById('include-cta'); + const referenceSimilarCheckbox = document.getElementById('reference-similar'); + const resultContainer = document.getElementById('result-container'); + const resultContent = document.getElementById('result-content'); + const loadingIndicator = document.getElementById('loading-indicator'); + const alignmentScore = document.getElementById('alignment-score'); + const suggestionsList = document.getElementById('suggestions-list'); + const copyBtn = document.getElementById('copy-btn'); + const improveBtn = document.getElementById('improve-btn'); + const saveBtn = document.getElementById('save-btn'); + const improvementPanel = document.getElementById('improvement-panel'); + const improvementFeedback = document.getElementById('improvement-feedback'); + const submitImprovement = document.getElementById('submit-improvement'); + + // Brand Style Page + const toneSelector = document.getElementById('tone-selector'); + const voiceSelector = document.getElementById('voice-selector'); + const tabooWords = document.getElementById('taboo-words'); + const tabooInput = document.getElementById('taboo-input'); + const addTabooBtn = document.getElementById('add-taboo-btn'); + const avoidTerm = document.getElementById('avoid-term'); + const useTerm = document.getElementById('use-term'); + const addTermBtn = document.getElementById('add-term-btn'); + const saveBrandStyleBtn = document.getElementById('save-brand-style'); + const resetBrandStyleBtn = document.getElementById('reset-brand-style'); + + // Training Page + const trainingTabs = document.querySelectorAll('.training-tabs .tab'); + const tabContents = document.querySelectorAll('.tab-content'); + const addTrainingBtn = document.getElementById('add-training-btn'); + const trainingContentType = document.getElementById('training-content-type'); + const campaignName = document.getElementById('campaign-name'); + const trainingContent = document.getElementById('training-content'); + const openRate = document.getElementById('open-rate'); + const clickRate = document.getElementById('click-rate'); + const conversionRate = document.getElementById('conversion-rate'); + + // API Base URL + const API_URL = 'http://localhost:8000'; + + // Menu Navigation + menuItems.forEach(item => { + item.addEventListener('click', function() { + const pageName = this.getAttribute('data-page'); + + // Update active menu item + menuItems.forEach(menuItem => menuItem.classList.remove('active')); + this.classList.add('active'); + + // Show selected page + pages.forEach(page => { + if (page.id === `${pageName}-page`) { + page.classList.add('active'); + } else { + page.classList.remove('active'); + } + }); + }); + }); + + // Generate Content + if (generateBtn) { + generateBtn.addEventListener('click', function() { + if (!promptInput.value.trim()) { + alert('Please enter a prompt for content generation.'); + return; + } + + // Show loading indicator + loadingIndicator.classList.remove('hidden'); + resultContainer.classList.add('hidden'); + + // Prepare request data + const requestData = { + prompt: promptInput.value, + content_type: contentTypeSelect.value || null, + tone: toneSelect.value || null, + length: lengthSelect.value || null, + include_cta: includeCTACheckbox.checked, + reference_similar_content: referenceSimilarCheckbox.checked + }; + + // Call the API + fetch(`${API_URL}/generate-copy`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + // Hide loading indicator + loadingIndicator.classList.add('hidden'); + + // Display result + resultContent.textContent = data.content; + + // Set alignment score + const score = data.metadata.alignment_score || 0; + alignmentScore.style.width = `${score}%`; + alignmentScore.textContent = `${Math.round(score)}%`; + + if (score < 60) { + alignmentScore.style.backgroundColor = 'var(--danger-color)'; + } else if (score < 80) { + alignmentScore.style.backgroundColor = 'var(--warning-color)'; + } else { + alignmentScore.style.backgroundColor = 'var(--success-color)'; + } + + // Display suggestions + if (data.suggestions && data.suggestions.length > 0) { + suggestionsList.innerHTML = ''; + data.suggestions.forEach(suggestion => { + const li = document.createElement('li'); + li.textContent = suggestion; + li.addEventListener('click', function() { + promptInput.value = suggestion; + }); + suggestionsList.appendChild(li); + }); + } + + // Show result container + resultContainer.classList.remove('hidden'); + }) + .catch(error => { + console.error('Error:', error); + loadingIndicator.classList.add('hidden'); + alert('An error occurred while generating content. Please try again.'); + }); + }); + } + + // Copy to Clipboard + if (copyBtn) { + copyBtn.addEventListener('click', function() { + navigator.clipboard.writeText(resultContent.textContent) + .then(() => { + const originalTitle = copyBtn.getAttribute('title'); + copyBtn.setAttribute('title', 'Copied!'); + setTimeout(() => { + copyBtn.setAttribute('title', originalTitle); + }, 2000); + }) + .catch(err => { + console.error('Could not copy text: ', err); + }); + }); + } + + // Toggle Improvement Panel + if (improveBtn) { + improveBtn.addEventListener('click', function() { + improvementPanel.classList.toggle('hidden'); + }); + } + + // Submit Improvement Feedback + if (submitImprovement) { + submitImprovement.addEventListener('click', function() { + if (!improvementFeedback.value.trim()) { + alert('Please enter feedback for improvement.'); + return; + } + + // Show loading indicator + loadingIndicator.classList.remove('hidden'); + + // Prepare request data + const requestData = { + content: resultContent.textContent, + feedback: improvementFeedback.value + }; + + // Call the API + fetch(`${API_URL}/improve-content`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + // Hide loading indicator + loadingIndicator.classList.add('hidden'); + + // Update result content + resultContent.textContent = data.improved_content; + + // Hide improvement panel + improvementPanel.classList.add('hidden'); + improvementFeedback.value = ''; + }) + .catch(error => { + console.error('Error:', error); + loadingIndicator.classList.add('hidden'); + alert('An error occurred while improving content. Please try again.'); + }); + }); + } + + // Save Content to History + if (saveBtn) { + saveBtn.addEventListener('click', function() { + alert('Content saved to history!'); + // In a real implementation, you would save this to local storage + // or call an API to save it to the backend + }); + } + + // Brand Style Tag Selection + if (toneSelector) { + const tagElements = toneSelector.querySelectorAll('.tag'); + tagElements.forEach(tag => { + tag.addEventListener('click', function() { + this.classList.toggle('selected'); + }); + }); + } + + if (voiceSelector) { + const tagElements = voiceSelector.querySelectorAll('.tag'); + tagElements.forEach(tag => { + tag.addEventListener('click', function() { + this.classList.toggle('selected'); + }); + }); + } + + // Add Taboo Word + if (addTabooBtn && tabooInput && tabooWords) { + addTabooBtn.addEventListener('click', function() { + const word = tabooInput.value.trim(); + if (word) { + const tagElement = document.createElement('span'); + tagElement.classList.add('tag', 'removable'); + tagElement.innerHTML = `${word}`; + + // Add click event to remove the tag + tagElement.querySelector('i').addEventListener('click', function() { + tagElement.remove(); + }); + + tabooWords.appendChild(tagElement); + tabooInput.value = ''; + } + }); + + // Allow pressing Enter to add a word + tabooInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + addTabooBtn.click(); + } + }); + } + + // Add Terminology Term + if (addTermBtn && avoidTerm && useTerm) { + addTermBtn.addEventListener('click', function() { + const avoid = avoidTerm.value.trim(); + const use = useTerm.value.trim(); + + if (avoid && use) { + const tableRow = document.createElement('div'); + tableRow.classList.add('terminology-row'); + tableRow.innerHTML = ` +
${avoid}
+
${use}
+
+ +
+ `; + + // Add click event to remove the row + tableRow.querySelector('.btn-icon').addEventListener('click', function() { + tableRow.remove(); + }); + + // Insert before the add row + const addRow = document.querySelector('.terminology-row.add-row'); + addRow.parentNode.insertBefore(tableRow, addRow); + + avoidTerm.value = ''; + useTerm.value = ''; + } + }); + + // Allow pressing Enter to add a term + useTerm.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + addTermBtn.click(); + } + }); + } + + // Save Brand Style + if (saveBrandStyleBtn) { + saveBrandStyleBtn.addEventListener('click', function() { + // Collect tone tags + const selectedTones = []; + toneSelector.querySelectorAll('.tag.selected').forEach(tag => { + selectedTones.push(tag.textContent); + }); + + // Collect voice characteristics + const selectedVoice = []; + voiceSelector.querySelectorAll('.tag.selected').forEach(tag => { + selectedVoice.push(tag.textContent); + }); + + // Collect taboo words + const tabooWordsList = []; + tabooWords.querySelectorAll('.tag').forEach(tag => { + // Extract just the text without the 'x' icon + const text = tag.textContent.replace('×', '').trim(); + tabooWordsList.push(text); + }); + + // Collect preferred terms + const preferredTerms = {}; + document.querySelectorAll('.terminology-row:not(.add-row):not(.terminology-header)').forEach(row => { + const avoid = row.querySelector('.term-avoid').textContent.trim(); + const use = row.querySelector('.term-use').textContent.trim(); + if (avoid && use) { + preferredTerms[avoid] = use; + } + }); + + // Prepare request data + const requestData = { + tone: selectedTones, + voice_characteristics: selectedVoice, + taboo_words: tabooWordsList, + preferred_terms: preferredTerms + }; + + // Call the API + fetch(`${API_URL}/brand-style`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + alert('Brand style updated successfully!'); + }) + .catch(error => { + console.error('Error:', error); + alert('An error occurred while updating brand style. Please try again.'); + }); + }); + } + + // Reset Brand Style + if (resetBrandStyleBtn) { + resetBrandStyleBtn.addEventListener('click', function() { + if (confirm('Are you sure you want to reset brand style to defaults?')) { + // In a real implementation, you would call an API to reset + // For now, just reload the page + window.location.reload(); + } + }); + } + + // Training Tabs + if (trainingTabs.length > 0) { + trainingTabs.forEach(tab => { + tab.addEventListener('click', function() { + const tabName = this.getAttribute('data-tab'); + + // Update active tab + trainingTabs.forEach(t => t.classList.remove('active')); + this.classList.add('active'); + + // Show selected tab content + tabContents.forEach(content => { + if (content.id === `${tabName}-tab`) { + content.classList.add('active'); + } else { + content.classList.remove('active'); + } + }); + }); + }); + } + + // Add Training Data + if (addTrainingBtn) { + addTrainingBtn.addEventListener('click', function() { + if (!trainingContentType.value) { + alert('Please select a content type.'); + return; + } + + if (!trainingContent.value.trim()) { + alert('Please enter content for training.'); + return; + } + + // Prepare request data + const requestData = { + content_type: trainingContentType.value, + content: trainingContent.value, + metadata: { + campaign_name: campaignName.value, + performance_metrics: {} + } + }; + + // Add performance metrics if provided + if (openRate.value) { + requestData.metadata.performance_metrics.open_rate = parseFloat(openRate.value) / 100; + } + + if (clickRate.value) { + requestData.metadata.performance_metrics.click_rate = parseFloat(clickRate.value) / 100; + } + + if (conversionRate.value) { + requestData.metadata.performance_metrics.conversion_rate = parseFloat(conversionRate.value) / 100; + } + + // Call the API + fetch(`${API_URL}/training-data`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestData) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + alert('Training data added successfully!'); + + // Clear form + trainingContentType.value = ''; + campaignName.value = ''; + trainingContent.value = ''; + openRate.value = ''; + clickRate.value = ''; + conversionRate.value = ''; + + // Switch to view tab + document.querySelector('.tab[data-tab="view-training"]').click(); + + // In a real implementation, you would also refresh the training data list + }) + .catch(error => { + console.error('Error:', error); + alert('An error occurred while adding training data. Please try again.'); + }); + }); + } + + // Load Brand Style on Page Load + fetch(`${API_URL}/brand-style`) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + console.log('Loaded brand style:', data); + // In a real implementation, you would update the UI based on the loaded data + }) + .catch(error => { + console.error('Error loading brand style:', error); + }); + + // For demonstration purposes, let's create a mocked pre-filled content + // In a real implementation, this would be loaded from the backend + document.getElementById('prompt').value = 'Write a social media post about our new coaching program'; +}); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2d8977b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,498 @@ + + + + + + Adriana James - Marketing Assistant AI + + + + + +
+ + +
+
+

Marketing Assistant AI

+
+ + +
+
+ + +
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
+ + + + +
+ + +
+ + +
+
+
+

Email Welcome Sequence

+

A 5-part email sequence for new subscribers.

+ +
+
+
+

Product Launch Posts

+

Social media templates for product launches.

+ +
+
+
+

Transformation Story

+

Client success story blog post template.

+ +
+
+
+

Workshop Promotion

+

Ad copy template for promoting workshops.

+ +
+
+
+

Create New Template

+

Build a custom template from scratch.

+ +
+
+
+ + +
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+

Transformation Masterclass Invitation

+

Subject: Transform Your Potential with Adriana James' Exclusive Workshop...

+
+
Apr 17, 2025
+
+ + + +
+
+
+ +
+

3-Step Framework Post

+

BREAKTHROUGH MOMENT ✨ Ever feel stuck in patterns that hold you back...

+
+
Apr 16, 2025
+
+ + + +
+
+
+
Blog
+
+

5 Ways to Overcome Limiting Beliefs

+

Are limiting beliefs holding you back from achieving your full potential?...

+
+
Apr 14, 2025
+
+ + + +
+
+
+
+ + +
+ + +
+
+

Brand Tone

+

Select the tone options that best represent the brand.

+
+ professional + friendly + inspirational + empowering + excited + authoritative + casual + humorous +
+
+ +
+

Voice Characteristics

+

Define the key characteristics of the brand voice.

+
+ clear + direct + empowering + confident + authentic + innovative + visionary + approachable +
+
+ +
+

Taboo Words

+

Words to avoid in all marketing content.

+
+
+ cheap + discount + bargain + failure + impossible + difficult +
+
+ + +
+
+
+ +
+

Preferred Terminology

+

Preferred terms to use instead of common alternatives.

+
+
+
Avoid
+
Use Instead
+
+
+
+
customers
+
clients
+
+ +
+
+
+
products
+
solutions
+
+ +
+
+
+
problems
+
challenges
+
+ +
+
+
+
services
+
experiences
+
+ +
+
+
+
training
+
transformation
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+ + +
+
+
+ + +
+ + +
+
Add Training Data
+
View Training Data
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+

Performance Metrics

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+

Transformation Masterclass Promotion

+

Added on: Apr 15, 2025

+
+ Open Rate: 42% + Click Rate: 18% + Conversion: 8% +
+
+
+ + +
+
+
+ +
+

Breakthrough Framework

+

Added on: Apr 10, 2025

+
+ Engagement: 6.4% + Saves: 178 + Shares: 92 +
+
+
+ + +
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..4661797 --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,964 @@ +/* Global Variables */ +:root { + --primary-color: #6236FF; + --primary-light: #8E6FFF; + --primary-dark: #4B2AD8; + --secondary-color: #FFB800; + --success-color: #1AC888; + --danger-color: #FF4757; + --warning-color: #FFBA00; + --dark-color: #161925; + --grey-100: #F5F7FA; + --grey-200: #E4E8F0; + --grey-300: #CBD2E0; + --grey-400: #9AA5B9; + --grey-500: #6B7A99; + --grey-600: #4A5568; + --grey-700: #2D3748; + --grey-800: #1A202C; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 8px 20px rgba(0, 0, 0, 0.15); + --font-family: 'Poppins', sans-serif; +} + +/* Reset & Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + font-size: 15px; + line-height: 1.5; + color: var(--grey-700); + background-color: var(--grey-100); +} + +h1, h2, h3, h4, h5, h6 { + margin-bottom: 0.5rem; + font-weight: 600; + line-height: 1.2; + color: var(--grey-800); +} + +a { + color: var(--primary-color); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Layout */ +.app-container { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 240px; + background-color: var(--grey-800); + display: flex; + flex-direction: column; + color: white; + padding: 20px 0; + position: fixed; + height: 100vh; + box-shadow: var(--shadow-md); +} + +.content { + flex: 1; + margin-left: 240px; + padding: 20px; + max-width: calc(100vw - 240px); +} + +/* Logo */ +.logo { + padding: 10px 20px 30px; + text-align: center; +} + +.logo h2 { + color: white; + background-color: var(--primary-color); + width: 60px; + height: 60px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; + font-size: 26px; + font-weight: 700; +} + +/* Menu */ +.menu { + list-style: none; + margin-bottom: auto; +} + +.menu li { + padding: 12px 20px; + cursor: pointer; + display: flex; + align-items: center; + transition: background-color 0.2s; + color: var(--grey-300); + margin-bottom: 4px; + border-left: 3px solid transparent; +} + +.menu li i { + margin-right: 12px; + font-size: 16px; + width: 20px; + text-align: center; +} + +.menu li:hover { + background-color: rgba(255, 255, 255, 0.05); + color: white; +} + +.menu li.active { + background-color: rgba(255, 255, 255, 0.1); + color: white; + border-left: 3px solid var(--primary-color); +} + +/* User Info */ +.user-info { + display: flex; + align-items: center; + padding: 16px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin-top: auto; +} + +.user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + overflow: hidden; + margin-right: 12px; +} + +.user-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.user-name { + font-weight: 500; + font-size: 14px; +} + +/* Header */ +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 1px solid var(--grey-200); +} + +.header-actions { + display: flex; + gap: 10px; +} + +/* Page */ +.page { + display: none; +} + +.page.active { + display: block; +} + +.page-header { + margin-bottom: 30px; +} + +.page-header h2 { + margin-bottom: 8px; + font-size: 24px; +} + +.page-header p { + color: var(--grey-500); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: var(--radius-md); + border: none; + font-weight: 500; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.btn i { + margin-right: 8px; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-dark); +} + +.btn-secondary { + background-color: var(--grey-200); + color: var(--grey-700); +} + +.btn-secondary:hover { + background-color: var(--grey-300); +} + +.btn-outline { + background-color: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); +} + +.btn-outline:hover { + background-color: var(--primary-color); + color: white; +} + +.btn-lg { + padding: 12px 20px; + font-size: 16px; +} + +.btn-icon { + padding: 8px; + border-radius: 50%; + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + color: var(--grey-600); + border: none; + cursor: pointer; + transition: all 0.2s; +} + +.btn-icon:hover { + background-color: var(--grey-200); + color: var(--grey-800); +} + +.btn-icon i { + margin-right: 0; +} + +/* Forms */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: var(--grey-700); +} + +.form-group input[type="text"], +.form-group input[type="number"], +.form-group input[type="email"], +.form-group input[type="password"], +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--grey-300); + font-family: var(--font-family); + font-size: 15px; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(98, 54, 255, 0.1); +} + +.form-row { + display: flex; + gap: 20px; + margin-bottom: 20px; +} + +.form-row .form-group { + flex: 1; + margin-bottom: 0; +} + +.form-actions { + margin-top: 30px; + display: flex; + gap: 15px; +} + +.form-section { + margin-bottom: 30px; +} + +.form-section h3 { + font-size: 18px; + margin-bottom: 12px; +} + +.form-section p { + margin-bottom: 15px; + color: var(--grey-500); +} + +/* Checkbox */ +.checkbox-group { + display: flex; + gap: 20px; +} + +.checkbox { + display: flex; + align-items: center; + position: relative; + padding-left: 30px; + cursor: pointer; + font-weight: normal; + user-select: none; +} + +.checkbox input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.checkmark { + position: absolute; + top: 0; + left: 0; + height: 20px; + width: 20px; + background-color: white; + border: 1px solid var(--grey-300); + border-radius: var(--radius-sm); +} + +.checkbox:hover input ~ .checkmark { + border-color: var(--grey-400); +} + +.checkbox input:checked ~ .checkmark { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +.checkmark:after { + content: ""; + position: absolute; + display: none; +} + +.checkbox input:checked ~ .checkmark:after { + display: block; +} + +.checkbox .checkmark:after { + left: 7px; + top: 3px; + width: 5px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +/* Generation Form */ +.generation-form { + background-color: white; + border-radius: var(--radius-lg); + padding: 25px; + box-shadow: var(--shadow-sm); + margin-bottom: 30px; +} + +/* Result Container */ +.result-container { + background-color: white; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + margin-top: 30px; +} + +.result-container.hidden { + display: none; +} + +.result-header { + padding: 20px 25px; + border-bottom: 1px solid var(--grey-200); + display: flex; + justify-content: space-between; + align-items: center; +} + +.result-header h3 { + margin-bottom: 0; +} + +.result-actions { + display: flex; + gap: 10px; +} + +.result-content { + padding: 25px; + white-space: pre-wrap; + line-height: 1.6; +} + +.metadata-panel { + background-color: var(--grey-100); + padding: 20px 25px; + border-top: 1px solid var(--grey-200); +} + +.metadata-item { + margin-bottom: 15px; +} + +.metadata-label { + display: block; + font-weight: 500; + margin-bottom: 5px; + color: var(--grey-600); +} + +.score-bar { + height: 8px; + background-color: var(--grey-200); + border-radius: 4px; + overflow: hidden; +} + +.score-fill { + height: 100%; + background-color: var(--success-color); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 10px; + font-weight: 700; + transition: width 0.5s ease; +} + +.suggestions-container { + margin-top: 20px; +} + +.suggestions-container h4 { + margin-bottom: 10px; + color: var(--grey-600); + font-size: 15px; +} + +.suggestions-list { + list-style: none; +} + +.suggestions-list li { + margin-bottom: 8px; + padding: 8px 12px; + background-color: white; + border-radius: var(--radius-md); + border: 1px solid var(--grey-200); + cursor: pointer; + transition: all 0.2s; +} + +.suggestions-list li:hover { + border-color: var(--primary-color); + background-color: rgba(98, 54, 255, 0.05); +} + +.improvement-panel { + padding: 20px 25px; + border-top: 1px solid var(--grey-200); +} + +.improvement-panel.hidden { + display: none; +} + +.improvement-panel h4 { + margin-bottom: 10px; +} + +/* Loading Indicator */ +.loading-indicator { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 40px 0; +} + +.loading-indicator.hidden { + display: none; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid var(--grey-200); + border-top: 4px solid var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 15px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Templates Grid */ +.templates-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.template-card { + background-color: white; + border-radius: var(--radius-lg); + padding: 25px; + box-shadow: var(--shadow-sm); + transition: all 0.2s; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.template-card:hover { + transform: translateY(-5px); + box-shadow: var(--shadow-md); +} + +.template-icon { + width: 60px; + height: 60px; + border-radius: 50%; + background-color: var(--primary-light); + color: white; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + font-size: 24px; +} + +.template-card h3 { + margin-bottom: 8px; +} + +.template-card p { + color: var(--grey-500); + margin-bottom: 20px; +} + +.template-card button { + margin-top: auto; +} + +.template-card.template-add { + border: 2px dashed var(--grey-300); + background-color: transparent; +} + +.template-card.template-add .template-icon { + background-color: var(--grey-300); +} + +.template-card.template-add:hover { + border-color: var(--primary-color); +} + +/* History and Training List */ +.history-filters, +.training-filters { + display: flex; + gap: 15px; + margin-bottom: 20px; +} + +.history-filters .form-group, +.training-filters .form-group { + margin-bottom: 0; + flex: 1; +} + +.history-list, +.training-list { + background-color: white; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.history-item, +.training-item { + display: flex; + padding: 15px 20px; + border-bottom: 1px solid var(--grey-200); + align-items: center; +} + +.history-item:last-child, +.training-item:last-child { + border-bottom: none; +} + +.history-item-type, +.training-item-type { + width: 80px; + text-align: center; + padding: 4px 8px; + border-radius: var(--radius-md); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.history-item-type.email_campaign, +.training-item-type.email_campaign { + background-color: var(--primary-light); + color: white; +} + +.history-item-type.social_media, +.training-item-type.social_media { + background-color: var(--secondary-color); + color: white; +} + +.history-item-type.blog_post, +.training-item-type.blog_post { + background-color: var(--success-color); + color: white; +} + +.history-item-type.website_copy, +.training-item-type.website_copy { + background-color: var(--warning-color); + color: white; +} + +.history-item-content, +.training-item-content { + flex: 1; + padding: 0 20px; +} + +.history-item-content h4, +.training-item-content h4 { + margin-bottom: 5px; +} + +.history-item-content p, +.training-item-content p { + color: var(--grey-500); + font-size: 14px; +} + +.history-item-date { + color: var(--grey-500); + font-size: 14px; + min-width: 100px; + text-align: right; + margin-right: 15px; +} + +.history-item-actions, +.training-item-actions { + display: flex; + gap: 5px; +} + +.metrics { + display: flex; + gap: 10px; + margin-top: 5px; +} + +.metric { + font-size: 13px; + color: var(--grey-600); + background-color: var(--grey-100); + padding: 2px 8px; + border-radius: var(--radius-sm); +} + +/* Brand Style */ +.tag-selector { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + +.tag { + display: inline-flex; + align-items: center; + padding: 6px 12px; + background-color: var(--grey-200); + color: var(--grey-700); + border-radius: 20px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.tag.selected { + background-color: var(--primary-color); + color: white; +} + +.tag.removable { + padding-right: 8px; +} + +.tag.removable i { + margin-left: 8px; + font-size: 12px; +} + +.tag-editor { + margin-top: 10px; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 10px; +} + +.tag-input-container { + display: flex; + gap: 10px; +} + +.tag-input-container input { + flex: 1; +} + +.terminology-table { + margin-top: 15px; + border: 1px solid var(--grey-200); + border-radius: var(--radius-md); + overflow: hidden; +} + +.terminology-header { + display: flex; + background-color: var(--grey-100); + padding: 12px 15px; + font-weight: 600; + border-bottom: 1px solid var(--grey-200); +} + +.terminology-row { + display: flex; + padding: 12px 15px; + border-bottom: 1px solid var(--grey-200); + align-items: center; +} + +.terminology-row:last-child { + border-bottom: none; +} + +.term-avoid { + flex: 1; +} + +.term-use { + flex: 1; +} + +.term-actions { + width: 50px; + text-align: right; +} + +.terminology-row.add-row input { + width: 100%; + border: none; + padding: 5px 0; + background: transparent; +} + +.terminology-row.add-row input:focus { + outline: none; + box-shadow: none; +} + +/* Training Tabs */ +.training-tabs { + display: flex; + margin-bottom: 20px; + border-bottom: 1px solid var(--grey-200); +} + +.tab { + padding: 12px 20px; + cursor: pointer; + position: relative; + color: var(--grey-500); +} + +.tab.active { + color: var(--primary-color); + font-weight: 500; +} + +.tab.active:after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + width: 100%; + height: 2px; + background-color: var(--primary-color); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Responsive */ +@media (max-width: 992px) { + .sidebar { + width: 80px; + padding: 15px 0; + } + + .logo h2 { + width: 50px; + height: 50px; + font-size: 20px; + } + + .menu li { + justify-content: center; + padding: 12px; + } + + .menu li i { + margin-right: 0; + font-size: 20px; + } + + .menu li span { + display: none; + } + + .user-info { + justify-content: center; + padding: 10px; + } + + .user-avatar { + margin-right: 0; + } + + .user-name { + display: none; + } + + .content { + margin-left: 80px; + max-width: calc(100vw - 80px); + } +} + +@media (max-width: 768px) { + .form-row { + flex-direction: column; + gap: 10px; + } + + .templates-grid { + grid-template-columns: 1fr; + } + + .history-item, + .training-item { + flex-direction: column; + align-items: flex-start; + } + + .history-item-type, + .training-item-type { + margin-bottom: 10px; + } + + .history-item-content, + .training-item-content { + padding: 0; + margin-bottom: 10px; + } + + .history-item-date { + text-align: left; + margin-bottom: 10px; + } + + .checkbox-group { + flex-direction: column; + gap: 10px; + } +} \ No newline at end of file diff --git a/logs/app.log b/logs/app.log new file mode 100644 index 0000000..d055e92 --- /dev/null +++ b/logs/app.log @@ -0,0 +1,460 @@ +2025-04-17 07:08:51.543 | WARNING | vector_store:search:159 - Empty vector store, no results to return +2025-04-17 07:08:51.543 | WARNING | vector_store:search:159 - Empty vector store, no results to return +2025-04-17 07:08:51.551 | INFO | copywriter:generate_copy:118 - Generated content with 159 characters +2025-04-17 07:08:51.551 | INFO | copywriter:generate_copy:118 - Generated content with 159 characters +2025-04-17 07:08:52.803 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:08:52.803 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:12:18.934 | INFO | vector_store:search:212 - Found 1 matching documents for query +2025-04-17 07:12:18.934 | INFO | vector_store:search:212 - Found 1 matching documents for query +2025-04-17 07:12:18.936 | INFO | copywriter:generate_copy:118 - Generated content with 159 characters +2025-04-17 07:12:18.936 | INFO | copywriter:generate_copy:118 - Generated content with 159 characters +2025-04-17 07:12:19.677 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:12:19.677 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:15:03.309 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:15:03.309 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:15:05.643 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:05.643 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:05.644 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:05.644 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:10.452 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:10.452 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:10.455 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:10.455 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:15.166 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:15.166 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:15.168 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:15.168 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:15.170 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:15:15.170 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:15:20.280 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:15:20.280 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:15:21.317 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:21.317 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:21.369 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:21.369 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:26.051 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:26.051 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:26.052 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:26.052 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:30.842 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:30.842 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:30.847 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:30.847 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:30.859 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:15:30.859 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:15:36.115 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:15:36.115 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:15:36.882 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:36.882 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:36.885 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:36.885 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:41.549 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:41.549 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:41.551 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:41.551 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:46.258 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:46.258 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 404, { + "error": { + "message": "The model `gpt-4` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} + +2025-04-17 07:15:46.266 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:46.266 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 404 +2025-04-17 07:15:46.269 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:15:46.269 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:15:46.274 | ERROR | main:generate_copy:157 - Error generating copy: RetryError[] +2025-04-17 07:15:46.274 | ERROR | main:generate_copy:157 - Error generating copy: RetryError[] +2025-04-17 07:18:54.993 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:18:54.993 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:18:57.991 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:18:57.991 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:18:57.993 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:18:57.993 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:02.717 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:02.717 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:02.719 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:02.719 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:07.525 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:07.525 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:07.526 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:07.526 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:07.527 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:19:07.527 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:19:12.302 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:19:12.302 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:19:13.063 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:13.063 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:13.064 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:13.064 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:21.192 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:21.192 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:21.199 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:21.199 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:26.353 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:26.353 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:26.360 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:26.360 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:26.364 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:19:26.364 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:19:31.480 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:19:31.480 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:19:32.593 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:32.593 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:32.597 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:32.597 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:37.418 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:37.418 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:37.425 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:37.425 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:42.179 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:42.179 | ERROR | copywriter:_call_llm_api:161 - OpenAI API error: 429, { + "error": { + "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + "type": "insufficient_quota", + "param": null, + "code": "insufficient_quota" + } +} + +2025-04-17 07:19:42.180 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:42.180 | ERROR | copywriter:_call_llm_api:165 - Error calling OpenAI API: OpenAI API error: 429 +2025-04-17 07:19:42.181 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:19:42.181 | ERROR | copywriter:generate_copy:122 - Error generating copy: RetryError[] +2025-04-17 07:19:42.182 | ERROR | main:generate_copy:157 - Error generating copy: RetryError[] +2025-04-17 07:19:42.182 | ERROR | main:generate_copy:157 - Error generating copy: RetryError[] +2025-04-17 07:23:26.426 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:23:26.426 | INFO | vector_store:search:212 - Found 2 matching documents for query +2025-04-17 07:23:41.296 | INFO | copywriter:generate_copy:118 - Generated content with 1092 characters +2025-04-17 07:23:41.296 | INFO | copywriter:generate_copy:118 - Generated content with 1092 characters +2025-04-17 07:23:41.800 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:23:41.800 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:24:54.053 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 07:24:54.053 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 07:25:04.622 | INFO | copywriter:generate_copy:118 - Generated content with 1528 characters +2025-04-17 07:25:04.622 | INFO | copywriter:generate_copy:118 - Generated content with 1528 characters +2025-04-17 07:25:05.154 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:25:05.154 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:36:21.399 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 07:36:21.399 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 07:36:38.021 | INFO | copywriter:generate_copy:118 - Generated content with 1506 characters +2025-04-17 07:36:38.021 | INFO | copywriter:generate_copy:118 - Generated content with 1506 characters +2025-04-17 07:36:38.691 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:36:38.691 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:52:38.745 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 07:52:38.745 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 07:52:43.989 | INFO | copywriter:generate_copy:118 - Generated content with 735 characters +2025-04-17 07:52:43.989 | INFO | copywriter:generate_copy:118 - Generated content with 735 characters +2025-04-17 07:52:44.389 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:52:44.389 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:53:48.816 | INFO | brand_style:update_style_guidelines:80 - Updated brand style guidelines +2025-04-17 07:53:48.816 | INFO | brand_style:update_style_guidelines:80 - Updated brand style guidelines +2025-04-17 07:53:53.715 | INFO | brand_style:update_style_guidelines:80 - Updated brand style guidelines +2025-04-17 07:53:53.715 | INFO | brand_style:update_style_guidelines:80 - Updated brand style guidelines +2025-04-17 07:57:41.845 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 07:57:41.845 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 07:57:49.623 | INFO | copywriter:generate_copy:118 - Generated content with 1037 characters +2025-04-17 07:57:49.623 | INFO | copywriter:generate_copy:118 - Generated content with 1037 characters +2025-04-17 07:57:49.997 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:57:49.997 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:58:37.795 | INFO | copywriter:generate_copy:118 - Generated content with 1229 characters +2025-04-17 07:58:37.795 | INFO | copywriter:generate_copy:118 - Generated content with 1229 characters +2025-04-17 07:58:38.334 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 07:58:38.334 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 08:00:19.501 | INFO | copywriter:improve_copy:221 - Improved content based on feedback +2025-04-17 08:00:19.501 | INFO | copywriter:improve_copy:221 - Improved content based on feedback +2025-04-17 08:02:10.367 | INFO | brand_style:update_style_guidelines:80 - Updated brand style guidelines +2025-04-17 08:02:10.367 | INFO | brand_style:update_style_guidelines:80 - Updated brand style guidelines +2025-04-17 08:03:00.533 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 08:03:00.533 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 08:03:15.382 | INFO | copywriter:generate_copy:118 - Generated content with 2057 characters +2025-04-17 08:03:15.382 | INFO | copywriter:generate_copy:118 - Generated content with 2057 characters +2025-04-17 08:03:15.964 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 08:03:15.964 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 08:04:49.387 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 08:04:49.387 | INFO | vector_store:search:212 - Found 3 matching documents for query +2025-04-17 08:05:19.792 | ERROR | copywriter:_call_llm_api:167 - Error calling Cohere API: +2025-04-17 08:05:19.792 | ERROR | copywriter:_call_llm_api:167 - Error calling Cohere API: +2025-04-17 08:05:33.019 | INFO | copywriter:generate_copy:118 - Generated content with 938 characters +2025-04-17 08:05:33.019 | INFO | copywriter:generate_copy:118 - Generated content with 938 characters +2025-04-17 08:05:33.540 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 08:05:33.540 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 08:08:22.724 | INFO | brand_style:update_style_guidelines:80 - Updated brand style guidelines +2025-04-17 08:08:22.724 | INFO | brand_style:update_style_guidelines:80 - Updated brand style guidelines +2025-04-17 08:10:36.577 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store +2025-04-17 08:10:36.577 | INFO | vector_store:add_documents:131 - Added 1 documents to vector store