diff --git a/.cursor/skills/linkedin-content/SKILL.md b/.cursor/skills/linkedin-content/SKILL.md new file mode 100644 index 0000000..9597c98 --- /dev/null +++ b/.cursor/skills/linkedin-content/SKILL.md @@ -0,0 +1,231 @@ +--- +name: linkedin-content +description: "LinkedIn post writing with hook formulas, formatting rules, and engagement patterns. Covers post types, algorithm signals, character limits, and content pillars. Use for: LinkedIn posts, professional content, thought leadership, B2B content, personal branding. Triggers: linkedin post, linkedin content, linkedin writing, linkedin strategy, linkedin engagement, linkedin algorithm, linkedin hook, linkedin formatting, thought leadership, professional content, b2b content, linkedin growth" +allowed-tools: Bash(infsh *) +--- + +# LinkedIn Content + +Write high-engagement LinkedIn posts via [inference.sh](https://inference.sh) CLI. + +## Quick Start + +```bash +curl -fsSL https://cli.inference.sh | sh && infsh login + +# Research trending LinkedIn content patterns +infsh app run tavily/search-assistant --input '{ + "query": "LinkedIn viral post examples 2024 high engagement patterns" +}' + +# Post to X (cross-posting reference) +infsh app run x/post-create --input '{ + "text": "Your cross-posted version here" +}' +``` + +> **Install note:** The [install script](https://cli.inference.sh) only detects your OS/architecture, downloads the matching binary from `dist.inference.sh`, and verifies its SHA-256 checksum. No elevated permissions or background processes. [Manual install & verification](https://dist.inference.sh/cli/checksums.txt) available. + +## Post Anatomy + +``` +┌─────────────────────────────────────┐ +│ HOOK (first 1-2 lines) │ ← Visible before "...see more" +│ │ +│ ...see more ─────────────────────── │ ← The click gate +│ │ +│ BODY (story/value) │ +│ - Formatted with line breaks │ +│ - Short paragraphs (1-2 sentences) │ +│ - Lists or numbered points │ +│ │ +│ CTA (last 1-2 lines) │ ← Ask for engagement +│ │ +│ #hashtags (3-5) │ +└─────────────────────────────────────┘ +``` + +## Character Limits + +| Element | Limit | +|---------|-------| +| Post text | 3,000 characters | +| Visible before "see more" | ~210 characters (~2 lines on mobile) | +| Hashtags | 3-5 recommended | +| Comment | 1,250 characters | +| Article title | 100 characters | +| Article body | 125,000 characters | + +**The first 210 characters are everything.** If the hook fails, nobody clicks "see more." + +## Hook Formulas + +### What Works + +| Formula | Example | +|---------|---------| +| Contrarian opinion | "Unpopular opinion: code reviews are a waste of time." | +| Personal story opening | "I got fired on a Tuesday. Best thing that ever happened." | +| Surprising stat | "92% of startups fail. But not for the reason you think." | +| List promise | "I've hired 200+ engineers. Here are 5 red flags I look for." | +| Bold statement | "Your resume doesn't matter. Here's what does." | +| Before/after | "3 years ago I couldn't get a single interview. Yesterday I turned down a FAANG offer." | +| Pattern interrupt | "Stop. Before you send that cold email, read this." | + +### What Fails + +``` +❌ "Excited to announce that we are pleased to share..." (corporate speak) +❌ "In today's rapidly evolving landscape..." (cliché, says nothing) +❌ "I'd like to take a moment to..." (slow, no hook) +❌ "Just published a new blog post!" (no value proposition) +❌ Starting with a hashtag or emoji +``` + +## Formatting Rules + +### Line Breaks Are Your Best Friend + +``` +❌ Dense paragraph: +"I learned something important about leadership last week. My team was struggling with a deadline and instead of pushing harder, I decided to remove scope. The result was incredible — we shipped faster and the quality was better. Sometimes less really is more." + +✅ Formatted for LinkedIn: +"I learned something about leadership last week. + +My team was struggling with a deadline. + +Instead of pushing harder, I removed scope. + +The result? + +We shipped faster. +And the quality was BETTER. + +Sometimes less really is more." +``` + +### Formatting Guidelines + +| Rule | Why | +|------|-----| +| One sentence per line | Easier to scan on mobile | +| Blank line between paragraphs | Visual breathing room | +| Short paragraphs (1-2 sentences) | Mobile readability | +| Use line breaks for dramatic effect | Creates pacing and suspense | +| Bold key phrases sparingly | Draws eye to important points | +| Numbered lists for tips | Scannable, shareable | +| Avoid walls of text | Nobody reads them | + +## Post Types (Ranked by Engagement) + +| Post Type | Engagement | Best For | +|-----------|-----------|----------| +| **Personal story + lesson** | Very High | Building connection, authenticity | +| **Contrarian take** | High | Starting conversations, visibility | +| **Carousel (document post)** | High | Educational content, tips | +| **List/tips (numbered)** | High | Actionable value, saves | +| **Poll** | Medium-High | Easy engagement, data gathering | +| **Photo + story** | Medium | Humanizing, events | +| **Video (native)** | Medium | Demonstrations, personality | +| **Link post** | Low | Driving traffic (algorithm penalizes) | +| **Reshare** | Very Low | Don't bother — write original | + +### Link Posts Strategy + +LinkedIn penalizes posts with links (reduces reach). Workarounds: + +1. **Comment method**: Post without link, add link as first comment, edit post to say "Link in comments" +2. **No-link method**: Summarize the content in the post itself, mention "DM for link" +3. **If you must link**: Put it at the very end, after strong standalone content + +## Content Pillars + +Every LinkedIn creator should have 3-5 pillars they rotate through: + +| Pillar | What It Covers | Example | +|--------|---------------|---------| +| **Expertise** | Industry knowledge, how-tos | "5 database patterns every engineer should know" | +| **Stories** | Personal experiences, failures, wins | "The hardest feedback I ever received" | +| **Opinions** | Takes on industry trends, contrarian views | "AI won't replace engineers. Bad managers will." | +| **Behind the scenes** | Building in public, process | "Here's our actual sprint retrospective format" | +| **Curated insights** | Trends, data, research summaries | "I analyzed 500 job postings. Here's what changed." | + +## Algorithm Signals + +| Signal | Impact | How | +|--------|--------|-----| +| **Dwell time** | Very High | Longer posts that people read fully | +| **Comments** | Very High | Ask questions, create discussion | +| **Saves** | High | Actionable, reference-worthy content | +| **"See more" clicks** | High | Strong hook that makes people expand | +| **Shares** | Medium | Relatable, quotable content | +| **Reactions** | Medium | Easy to get but weighted less | +| **External links** | Negative | Reduces reach — put links in comments | +| **Editing after posting** | Negative | Don't edit within first hour | +| **Posting frequency** | 3-5x/week | Daily is fine, more than 1/day hurts | + +## Posting Schedule + +| Day | Best Time (your audience's timezone) | +|-----|------| +| Tuesday-Thursday | 7-8 AM, 12 PM, 5-6 PM | +| Monday | 8 AM (people catching up) | +| Friday | 7-8 AM (before checkout) | +| Weekend | Skip or light content | + +**Engage in comments for 30-60 minutes after posting** — this is more important than the post itself. + +## Visual Content + +```bash +# Generate a visual for a LinkedIn post +infsh app run infsh/html-to-image --input '{ + "html": "

The best code is the code you don't write

— Every senior engineer

" +}' + +# Generate a professional photo for a personal post +infsh app run falai/flux-dev-lora --input '{ + "prompt": "candid professional photo, person speaking at a conference podium, audience in background blurred, natural stage lighting, authentic moment, corporate event photography", + "width": 1200, + "height": 900 +}' +``` + +## CTA Formulas + +End every post with engagement driver: + +| CTA Type | Example | +|----------|---------| +| Question | "What's the worst career advice you've received?" | +| Agreement check | "Agree or disagree?" | +| Share request | "Repost if this resonates ♻️" | +| Save prompt | "Save this for your next [situation] 🔖" | +| Recommendation ask | "What would you add to this list?" | +| Experience ask | "Has this happened to you?" | + +## Common Mistakes + +| Mistake | Problem | Fix | +|---------|---------|-----| +| Weak hook | Nobody clicks "see more" | Use hook formulas above | +| Wall of text | Unreadable on mobile | One sentence per line, blank lines between | +| Links in main post | Algorithm reduces reach | Put links in first comment | +| Too many hashtags | Looks spammy | 3-5 relevant hashtags max | +| Corporate jargon | "Leveraging synergies" = instant scroll past | Write like you talk | +| Only self-promotion | Audience stops engaging | 80% value, 20% promotion | +| No CTA | No engagement direction | Always end with a question or ask | +| Resharing without adding | Near-zero reach | Write original posts, quote instead | +| Posting and disappearing | Kills comment momentum | Engage for 30-60 min after posting | +| Being generic | "Hard work pays off" = invisible | Specific stories and data | + +## Related Skills + +```bash +npx skills add inference-sh/skills@social-media-carousel +npx skills add inference-sh/skills@content-repurposing +npx skills add inference-sh/skills@twitter-thread-creation +``` + +Browse all apps: `infsh app list` diff --git a/.cursor/skills/prompt-engineering-patterns/SKILL.md b/.cursor/skills/prompt-engineering-patterns/SKILL.md new file mode 100644 index 0000000..016cbe2 --- /dev/null +++ b/.cursor/skills/prompt-engineering-patterns/SKILL.md @@ -0,0 +1,480 @@ +--- +name: prompt-engineering-patterns +description: Master advanced prompt engineering techniques to maximize LLM performance, reliability, and controllability in production. Use when optimizing prompts, improving LLM outputs, or designing production prompt templates. +--- + +# Prompt Engineering Patterns + +Master advanced prompt engineering techniques to maximize LLM performance, reliability, and controllability. + +## When to Use This Skill + +- Designing complex prompts for production LLM applications +- Optimizing prompt performance and consistency +- Implementing structured reasoning patterns (chain-of-thought, tree-of-thought) +- Building few-shot learning systems with dynamic example selection +- Creating reusable prompt templates with variable interpolation +- Debugging and refining prompts that produce inconsistent outputs +- Implementing system prompts for specialized AI assistants +- Using structured outputs (JSON mode) for reliable parsing + +## Core Capabilities + +### 1. Few-Shot Learning + +- Example selection strategies (semantic similarity, diversity sampling) +- Balancing example count with context window constraints +- Constructing effective demonstrations with input-output pairs +- Dynamic example retrieval from knowledge bases +- Handling edge cases through strategic example selection + +### 2. Chain-of-Thought Prompting + +- Step-by-step reasoning elicitation +- Zero-shot CoT with "Let's think step by step" +- Few-shot CoT with reasoning traces +- Self-consistency techniques (sampling multiple reasoning paths) +- Verification and validation steps + +### 3. Structured Outputs + +- JSON mode for reliable parsing +- Pydantic schema enforcement +- Type-safe response handling +- Error handling for malformed outputs + +### 4. Prompt Optimization + +- Iterative refinement workflows +- A/B testing prompt variations +- Measuring prompt performance metrics (accuracy, consistency, latency) +- Reducing token usage while maintaining quality +- Handling edge cases and failure modes + +### 5. Template Systems + +- Variable interpolation and formatting +- Conditional prompt sections +- Multi-turn conversation templates +- Role-based prompt composition +- Modular prompt components + +### 6. System Prompt Design + +- Setting model behavior and constraints +- Defining output formats and structure +- Establishing role and expertise +- Safety guidelines and content policies +- Context setting and background information + +## Quick Start + +```python +from langchain_anthropic import ChatAnthropic +from langchain_core.prompts import ChatPromptTemplate +from pydantic import BaseModel, Field + +# Define structured output schema +class SQLQuery(BaseModel): + query: str = Field(description="The SQL query") + explanation: str = Field(description="Brief explanation of what the query does") + tables_used: list[str] = Field(description="List of tables referenced") + +# Initialize model with structured output +llm = ChatAnthropic(model="claude-sonnet-4-6") +structured_llm = llm.with_structured_output(SQLQuery) + +# Create prompt template +prompt = ChatPromptTemplate.from_messages([ + ("system", """You are an expert SQL developer. Generate efficient, secure SQL queries. + Always use parameterized queries to prevent SQL injection. + Explain your reasoning briefly."""), + ("user", "Convert this to SQL: {query}") +]) + +# Create chain +chain = prompt | structured_llm + +# Use +result = await chain.ainvoke({ + "query": "Find all users who registered in the last 30 days" +}) +print(result.query) +print(result.explanation) +``` + +## Key Patterns + +### Pattern 1: Structured Output with Pydantic + +```python +from anthropic import Anthropic +from pydantic import BaseModel, Field +from typing import Literal +import json + +class SentimentAnalysis(BaseModel): + sentiment: Literal["positive", "negative", "neutral"] + confidence: float = Field(ge=0, le=1) + key_phrases: list[str] + reasoning: str + +async def analyze_sentiment(text: str) -> SentimentAnalysis: + """Analyze sentiment with structured output.""" + client = Anthropic() + + message = client.messages.create( + model="claude-sonnet-4-6", + max_tokens=500, + messages=[{ + "role": "user", + "content": f"""Analyze the sentiment of this text. + +Text: {text} + +Respond with JSON matching this schema: +{{ + "sentiment": "positive" | "negative" | "neutral", + "confidence": 0.0-1.0, + "key_phrases": ["phrase1", "phrase2"], + "reasoning": "brief explanation" +}}""" + }] + ) + + return SentimentAnalysis(**json.loads(message.content[0].text)) +``` + +### Pattern 2: Chain-of-Thought with Self-Verification + +```python +from langchain_core.prompts import ChatPromptTemplate + +cot_prompt = ChatPromptTemplate.from_template(""" +Solve this problem step by step. + +Problem: {problem} + +Instructions: +1. Break down the problem into clear steps +2. Work through each step showing your reasoning +3. State your final answer +4. Verify your answer by checking it against the original problem + +Format your response as: +## Steps +[Your step-by-step reasoning] + +## Answer +[Your final answer] + +## Verification +[Check that your answer is correct] +""") +``` + +### Pattern 3: Few-Shot with Dynamic Example Selection + +```python +from langchain_voyageai import VoyageAIEmbeddings +from langchain_core.example_selectors import SemanticSimilarityExampleSelector +from langchain_chroma import Chroma + +# Create example selector with semantic similarity +example_selector = SemanticSimilarityExampleSelector.from_examples( + examples=[ + {"input": "How do I reset my password?", "output": "Go to Settings > Security > Reset Password"}, + {"input": "Where can I see my order history?", "output": "Navigate to Account > Orders"}, + {"input": "How do I contact support?", "output": "Click Help > Contact Us or email support@example.com"}, + ], + embeddings=VoyageAIEmbeddings(model="voyage-3-large"), + vectorstore_cls=Chroma, + k=2 # Select 2 most similar examples +) + +async def get_few_shot_prompt(query: str) -> str: + """Build prompt with dynamically selected examples.""" + examples = await example_selector.aselect_examples({"input": query}) + + examples_text = "\n".join( + f"User: {ex['input']}\nAssistant: {ex['output']}" + for ex in examples + ) + + return f"""You are a helpful customer support assistant. + +Here are some example interactions: +{examples_text} + +Now respond to this query: +User: {query} +Assistant:""" +``` + +### Pattern 4: Progressive Disclosure + +Start with simple prompts, add complexity only when needed: + +```python +PROMPT_LEVELS = { + # Level 1: Direct instruction + "simple": "Summarize this article: {text}", + + # Level 2: Add constraints + "constrained": """Summarize this article in 3 bullet points, focusing on: +- Key findings +- Main conclusions +- Practical implications + +Article: {text}""", + + # Level 3: Add reasoning + "reasoning": """Read this article carefully. +1. First, identify the main topic and thesis +2. Then, extract the key supporting points +3. Finally, summarize in 3 bullet points + +Article: {text} + +Summary:""", + + # Level 4: Add examples + "few_shot": """Read articles and provide concise summaries. + +Example: +Article: "New research shows that regular exercise can reduce anxiety by up to 40%..." +Summary: +• Regular exercise reduces anxiety by up to 40% +• 30 minutes of moderate activity 3x/week is sufficient +• Benefits appear within 2 weeks of starting + +Now summarize this article: +Article: {text} + +Summary:""" +} +``` + +### Pattern 5: Error Recovery and Fallback + +```python +from pydantic import BaseModel, ValidationError +import json + +class ResponseWithConfidence(BaseModel): + answer: str + confidence: float + sources: list[str] + alternative_interpretations: list[str] = [] + +ERROR_RECOVERY_PROMPT = """ +Answer the question based on the context provided. + +Context: {context} +Question: {question} + +Instructions: +1. If you can answer confidently (>0.8), provide a direct answer +2. If you're somewhat confident (0.5-0.8), provide your best answer with caveats +3. If you're uncertain (<0.5), explain what information is missing +4. Always provide alternative interpretations if the question is ambiguous + +Respond in JSON: +{{ + "answer": "your answer or 'I cannot determine this from the context'", + "confidence": 0.0-1.0, + "sources": ["relevant context excerpts"], + "alternative_interpretations": ["if question is ambiguous"] +}} +""" + +async def answer_with_fallback( + context: str, + question: str, + llm +) -> ResponseWithConfidence: + """Answer with error recovery and fallback.""" + prompt = ERROR_RECOVERY_PROMPT.format(context=context, question=question) + + try: + response = await llm.ainvoke(prompt) + return ResponseWithConfidence(**json.loads(response.content)) + except (json.JSONDecodeError, ValidationError) as e: + # Fallback: try to extract answer without structure + simple_prompt = f"Based on: {context}\n\nAnswer: {question}" + simple_response = await llm.ainvoke(simple_prompt) + return ResponseWithConfidence( + answer=simple_response.content, + confidence=0.5, + sources=["fallback extraction"], + alternative_interpretations=[] + ) +``` + +### Pattern 6: Role-Based System Prompts + +```python +SYSTEM_PROMPTS = { + "analyst": """You are a senior data analyst with expertise in SQL, Python, and business intelligence. + +Your responsibilities: +- Write efficient, well-documented queries +- Explain your analysis methodology +- Highlight key insights and recommendations +- Flag any data quality concerns + +Communication style: +- Be precise and technical when discussing methodology +- Translate technical findings into business impact +- Use clear visualizations when helpful""", + + "assistant": """You are a helpful AI assistant focused on accuracy and clarity. + +Core principles: +- Always cite sources when making factual claims +- Acknowledge uncertainty rather than guessing +- Ask clarifying questions when the request is ambiguous +- Provide step-by-step explanations for complex topics + +Constraints: +- Do not provide medical, legal, or financial advice +- Redirect harmful requests appropriately +- Protect user privacy""", + + "code_reviewer": """You are a senior software engineer conducting code reviews. + +Review criteria: +- Correctness: Does the code work as intended? +- Security: Are there any vulnerabilities? +- Performance: Are there efficiency concerns? +- Maintainability: Is the code readable and well-structured? +- Best practices: Does it follow language idioms? + +Output format: +1. Summary assessment (approve/request changes) +2. Critical issues (must fix) +3. Suggestions (nice to have) +4. Positive feedback (what's done well)""" +} +``` + +## Integration Patterns + +### With RAG Systems + +```python +RAG_PROMPT = """You are a knowledgeable assistant that answers questions based on provided context. + +Context (retrieved from knowledge base): +{context} + +Instructions: +1. Answer ONLY based on the provided context +2. If the context doesn't contain the answer, say "I don't have information about that in my knowledge base" +3. Cite specific passages using [1], [2] notation +4. If the question is ambiguous, ask for clarification + +Question: {question} + +Answer:""" +``` + +### With Validation and Verification + +```python +VALIDATED_PROMPT = """Complete the following task: + +Task: {task} + +After generating your response, verify it meets ALL these criteria: +✓ Directly addresses the original request +✓ Contains no factual errors +✓ Is appropriately detailed (not too brief, not too verbose) +✓ Uses proper formatting +✓ Is safe and appropriate + +If verification fails on any criterion, revise before responding. + +Response:""" +``` + +## Performance Optimization + +### Token Efficiency + +```python +# Before: Verbose prompt (150+ tokens) +verbose_prompt = """ +I would like you to please take the following text and provide me with a comprehensive +summary of the main points. The summary should capture the key ideas and important details +while being concise and easy to understand. +""" + +# After: Concise prompt (30 tokens) +concise_prompt = """Summarize the key points concisely: + +{text} + +Summary:""" +``` + +### Caching Common Prefixes + +```python +from anthropic import Anthropic + +client = Anthropic() + +# Use prompt caching for repeated system prompts +response = client.messages.create( + model="claude-sonnet-4-6", + max_tokens=1000, + system=[ + { + "type": "text", + "text": LONG_SYSTEM_PROMPT, + "cache_control": {"type": "ephemeral"} + } + ], + messages=[{"role": "user", "content": user_query}] +) +``` + +## Best Practices + +1. **Be Specific**: Vague prompts produce inconsistent results +2. **Show, Don't Tell**: Examples are more effective than descriptions +3. **Use Structured Outputs**: Enforce schemas with Pydantic for reliability +4. **Test Extensively**: Evaluate on diverse, representative inputs +5. **Iterate Rapidly**: Small changes can have large impacts +6. **Monitor Performance**: Track metrics in production +7. **Version Control**: Treat prompts as code with proper versioning +8. **Document Intent**: Explain why prompts are structured as they are + +## Common Pitfalls + +- **Over-engineering**: Starting with complex prompts before trying simple ones +- **Example pollution**: Using examples that don't match the target task +- **Context overflow**: Exceeding token limits with excessive examples +- **Ambiguous instructions**: Leaving room for multiple interpretations +- **Ignoring edge cases**: Not testing on unusual or boundary inputs +- **No error handling**: Assuming outputs will always be well-formed +- **Hardcoded values**: Not parameterizing prompts for reuse + +## Success Metrics + +Track these KPIs for your prompts: + +- **Accuracy**: Correctness of outputs +- **Consistency**: Reproducibility across similar inputs +- **Latency**: Response time (P50, P95, P99) +- **Token Usage**: Average tokens per request +- **Success Rate**: Percentage of valid, parseable outputs +- **User Satisfaction**: Ratings and feedback + +## Resources + +- [Anthropic Prompt Engineering Guide](https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering) +- [Claude Prompt Caching](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching) +- [OpenAI Prompt Engineering](https://platform.openai.com/docs/guides/prompt-engineering) +- [LangChain Prompts](https://python.langchain.com/docs/concepts/prompts/) diff --git a/.cursor/skills/prompt-engineering-patterns/assets/few-shot-examples.json b/.cursor/skills/prompt-engineering-patterns/assets/few-shot-examples.json new file mode 100644 index 0000000..dc30b9f --- /dev/null +++ b/.cursor/skills/prompt-engineering-patterns/assets/few-shot-examples.json @@ -0,0 +1,106 @@ +{ + "sentiment_analysis": [ + { + "input": "This product exceeded my expectations! The quality is outstanding.", + "output": "Positive" + }, + { + "input": "Terrible experience. The item arrived damaged and customer service was unhelpful.", + "output": "Negative" + }, + { + "input": "The product works as described. Nothing special, but does the job.", + "output": "Neutral" + } + ], + "entity_extraction": [ + { + "input": "Apple CEO Tim Cook announced the new iPhone at an event in Cupertino on September 12th.", + "output": { + "persons": ["Tim Cook"], + "organizations": ["Apple"], + "products": ["iPhone"], + "locations": ["Cupertino"], + "dates": ["September 12th"] + } + }, + { + "input": "Microsoft acquired GitHub for $7.5 billion in 2018.", + "output": { + "persons": [], + "organizations": ["Microsoft", "GitHub"], + "products": [], + "locations": [], + "dates": ["2018"], + "monetary_values": ["$7.5 billion"] + } + } + ], + "code_generation": [ + { + "input": "Write a Python function to check if a string is a palindrome", + "output": "def is_palindrome(s: str) -> bool:\n \"\"\"Check if string is palindrome, ignoring case and spaces.\"\"\"\n # Remove spaces and convert to lowercase\n cleaned = s.replace(' ', '').lower()\n # Compare with reversed string\n return cleaned == cleaned[::-1]" + } + ], + "text_classification": [ + { + "input": "How do I reset my password?", + "output": "account_management" + }, + { + "input": "My order hasn't arrived yet. Where is it?", + "output": "shipping_inquiry" + }, + { + "input": "I'd like to cancel my subscription.", + "output": "subscription_cancellation" + }, + { + "input": "The app keeps crashing when I try to log in.", + "output": "technical_support" + } + ], + "data_transformation": [ + { + "input": "John Smith, john@email.com, (555) 123-4567", + "output": { + "name": "John Smith", + "email": "john@email.com", + "phone": "(555) 123-4567" + } + }, + { + "input": "Jane Doe | jane.doe@company.com | +1-555-987-6543", + "output": { + "name": "Jane Doe", + "email": "jane.doe@company.com", + "phone": "+1-555-987-6543" + } + } + ], + "question_answering": [ + { + "context": "The Eiffel Tower is a wrought-iron lattice tower in Paris, France. It was constructed from 1887 to 1889 and stands 324 meters (1,063 ft) tall.", + "question": "When was the Eiffel Tower built?", + "answer": "The Eiffel Tower was constructed from 1887 to 1889." + }, + { + "context": "Python 3.11 was released on October 24, 2022. It includes performance improvements and new features like exception groups and improved error messages.", + "question": "What are the new features in Python 3.11?", + "answer": "Python 3.11 includes exception groups, improved error messages, and performance improvements." + } + ], + "summarization": [ + { + "input": "Climate change refers to long-term shifts in global temperatures and weather patterns. While climate change is natural, human activities have been the main driver since the 1800s, primarily due to the burning of fossil fuels like coal, oil and gas which produces heat-trapping greenhouse gases. The consequences include rising sea levels, more extreme weather events, and threats to biodiversity.", + "output": "Climate change involves long-term alterations in global temperatures and weather patterns, primarily driven by human fossil fuel consumption since the 1800s, resulting in rising sea levels, extreme weather, and biodiversity threats." + } + ], + "sql_generation": [ + { + "schema": "users (id, name, email, created_at)\norders (id, user_id, total, order_date)", + "request": "Find all users who have placed orders totaling more than $1000", + "output": "SELECT u.id, u.name, u.email, SUM(o.total) as total_spent\nFROM users u\nJOIN orders o ON u.id = o.user_id\nGROUP BY u.id, u.name, u.email\nHAVING SUM(o.total) > 1000;" + } + ] +} diff --git a/.cursor/skills/prompt-engineering-patterns/assets/prompt-template-library.md b/.cursor/skills/prompt-engineering-patterns/assets/prompt-template-library.md new file mode 100644 index 0000000..cb2a785 --- /dev/null +++ b/.cursor/skills/prompt-engineering-patterns/assets/prompt-template-library.md @@ -0,0 +1,264 @@ +# Prompt Template Library + +## Classification Templates + +### Sentiment Analysis + +``` +Classify the sentiment of the following text as Positive, Negative, or Neutral. + +Text: {text} + +Sentiment: +``` + +### Intent Detection + +``` +Determine the user's intent from the following message. + +Possible intents: {intent_list} + +Message: {message} + +Intent: +``` + +### Topic Classification + +``` +Classify the following article into one of these categories: {categories} + +Article: +{article} + +Category: +``` + +## Extraction Templates + +### Named Entity Recognition + +``` +Extract all named entities from the text and categorize them. + +Text: {text} + +Entities (JSON format): +{ + "persons": [], + "organizations": [], + "locations": [], + "dates": [] +} +``` + +### Structured Data Extraction + +``` +Extract structured information from the job posting. + +Job Posting: +{posting} + +Extracted Information (JSON): +{ + "title": "", + "company": "", + "location": "", + "salary_range": "", + "requirements": [], + "responsibilities": [] +} +``` + +## Generation Templates + +### Email Generation + +``` +Write a professional {email_type} email. + +To: {recipient} +Context: {context} +Key points to include: +{key_points} + +Email: +Subject: +Body: +``` + +### Code Generation + +``` +Generate {language} code for the following task: + +Task: {task_description} + +Requirements: +{requirements} + +Include: +- Error handling +- Input validation +- Inline comments + +Code: +``` + +### Creative Writing + +``` +Write a {length}-word {style} story about {topic}. + +Include these elements: +- {element_1} +- {element_2} +- {element_3} + +Story: +``` + +## Transformation Templates + +### Summarization + +``` +Summarize the following text in {num_sentences} sentences. + +Text: +{text} + +Summary: +``` + +### Translation with Context + +``` +Translate the following {source_lang} text to {target_lang}. + +Context: {context} +Tone: {tone} + +Text: {text} + +Translation: +``` + +### Format Conversion + +``` +Convert the following {source_format} to {target_format}. + +Input: +{input_data} + +Output ({target_format}): +``` + +## Analysis Templates + +### Code Review + +``` +Review the following code for: +1. Bugs and errors +2. Performance issues +3. Security vulnerabilities +4. Best practice violations + +Code: +{code} + +Review: +``` + +### SWOT Analysis + +``` +Conduct a SWOT analysis for: {subject} + +Context: {context} + +Analysis: +Strengths: +- + +Weaknesses: +- + +Opportunities: +- + +Threats: +- +``` + +## Question Answering Templates + +### RAG Template + +``` +Answer the question based on the provided context. If the context doesn't contain enough information, say so. + +Context: +{context} + +Question: {question} + +Answer: +``` + +### Multi-Turn Q&A + +``` +Previous conversation: +{conversation_history} + +New question: {question} + +Answer (continue naturally from conversation): +``` + +## Specialized Templates + +### SQL Query Generation + +``` +Generate a SQL query for the following request. + +Database schema: +{schema} + +Request: {request} + +SQL Query: +``` + +### Regex Pattern Creation + +``` +Create a regex pattern to match: {requirement} + +Test cases that should match: +{positive_examples} + +Test cases that should NOT match: +{negative_examples} + +Regex pattern: +``` + +### API Documentation + +``` +Generate API documentation for this function: + +Code: +{function_code} + +Documentation (follow {doc_format} format): +``` + +## Use these templates by filling in the {variables} diff --git a/.cursor/skills/prompt-engineering-patterns/references/chain-of-thought.md b/.cursor/skills/prompt-engineering-patterns/references/chain-of-thought.md new file mode 100644 index 0000000..31be6ef --- /dev/null +++ b/.cursor/skills/prompt-engineering-patterns/references/chain-of-thought.md @@ -0,0 +1,412 @@ +# Chain-of-Thought Prompting + +## Overview + +Chain-of-Thought (CoT) prompting elicits step-by-step reasoning from LLMs, dramatically improving performance on complex reasoning, math, and logic tasks. + +## Core Techniques + +### Zero-Shot CoT + +Add a simple trigger phrase to elicit reasoning: + +```python +def zero_shot_cot(query): + return f"""{query} + +Let's think step by step:""" + +# Example +query = "If a train travels 60 mph for 2.5 hours, how far does it go?" +prompt = zero_shot_cot(query) + +# Model output: +# "Let's think step by step: +# 1. Speed = 60 miles per hour +# 2. Time = 2.5 hours +# 3. Distance = Speed × Time +# 4. Distance = 60 × 2.5 = 150 miles +# Answer: 150 miles" +``` + +### Few-Shot CoT + +Provide examples with explicit reasoning chains: + +```python +few_shot_examples = """ +Q: Roger has 5 tennis balls. He buys 2 more cans of tennis balls. Each can has 3 balls. How many tennis balls does he have now? +A: Let's think step by step: +1. Roger starts with 5 balls +2. He buys 2 cans, each with 3 balls +3. Balls from cans: 2 × 3 = 6 balls +4. Total: 5 + 6 = 11 balls +Answer: 11 + +Q: The cafeteria had 23 apples. If they used 20 to make lunch and bought 6 more, how many do they have? +A: Let's think step by step: +1. Started with 23 apples +2. Used 20 for lunch: 23 - 20 = 3 apples left +3. Bought 6 more: 3 + 6 = 9 apples +Answer: 9 + +Q: {user_query} +A: Let's think step by step:""" +``` + +### Self-Consistency + +Generate multiple reasoning paths and take the majority vote: + +```python +import openai +from collections import Counter + +def self_consistency_cot(query, n=5, temperature=0.7): + prompt = f"{query}\n\nLet's think step by step:" + + responses = [] + for _ in range(n): + response = openai.ChatCompletion.create( + model="gpt-5.2", + messages=[{"role": "user", "content": prompt}], + temperature=temperature + ) + responses.append(extract_final_answer(response)) + + # Take majority vote + answer_counts = Counter(responses) + final_answer = answer_counts.most_common(1)[0][0] + + return { + 'answer': final_answer, + 'confidence': answer_counts[final_answer] / n, + 'all_responses': responses + } +``` + +## Advanced Patterns + +### Least-to-Most Prompting + +Break complex problems into simpler subproblems: + +```python +def least_to_most_prompt(complex_query): + # Stage 1: Decomposition + decomp_prompt = f"""Break down this complex problem into simpler subproblems: + +Problem: {complex_query} + +Subproblems:""" + + subproblems = get_llm_response(decomp_prompt) + + # Stage 2: Sequential solving + solutions = [] + context = "" + + for subproblem in subproblems: + solve_prompt = f"""{context} + +Solve this subproblem: +{subproblem} + +Solution:""" + solution = get_llm_response(solve_prompt) + solutions.append(solution) + context += f"\n\nPreviously solved: {subproblem}\nSolution: {solution}" + + # Stage 3: Final integration + final_prompt = f"""Given these solutions to subproblems: +{context} + +Provide the final answer to: {complex_query} + +Final Answer:""" + + return get_llm_response(final_prompt) +``` + +### Tree-of-Thought (ToT) + +Explore multiple reasoning branches: + +```python +class TreeOfThought: + def __init__(self, llm_client, max_depth=3, branches_per_step=3): + self.client = llm_client + self.max_depth = max_depth + self.branches_per_step = branches_per_step + + def solve(self, problem): + # Generate initial thought branches + initial_thoughts = self.generate_thoughts(problem, depth=0) + + # Evaluate each branch + best_path = None + best_score = -1 + + for thought in initial_thoughts: + path, score = self.explore_branch(problem, thought, depth=1) + if score > best_score: + best_score = score + best_path = path + + return best_path + + def generate_thoughts(self, problem, context="", depth=0): + prompt = f"""Problem: {problem} +{context} + +Generate {self.branches_per_step} different next steps in solving this problem: + +1.""" + response = self.client.complete(prompt) + return self.parse_thoughts(response) + + def evaluate_thought(self, problem, thought_path): + prompt = f"""Problem: {problem} + +Reasoning path so far: +{thought_path} + +Rate this reasoning path from 0-10 for: +- Correctness +- Likelihood of reaching solution +- Logical coherence + +Score:""" + return float(self.client.complete(prompt)) +``` + +### Verification Step + +Add explicit verification to catch errors: + +```python +def cot_with_verification(query): + # Step 1: Generate reasoning and answer + reasoning_prompt = f"""{query} + +Let's solve this step by step:""" + + reasoning_response = get_llm_response(reasoning_prompt) + + # Step 2: Verify the reasoning + verification_prompt = f"""Original problem: {query} + +Proposed solution: +{reasoning_response} + +Verify this solution by: +1. Checking each step for logical errors +2. Verifying arithmetic calculations +3. Ensuring the final answer makes sense + +Is this solution correct? If not, what's wrong? + +Verification:""" + + verification = get_llm_response(verification_prompt) + + # Step 3: Revise if needed + if "incorrect" in verification.lower() or "error" in verification.lower(): + revision_prompt = f"""The previous solution had errors: +{verification} + +Please provide a corrected solution to: {query} + +Corrected solution:""" + return get_llm_response(revision_prompt) + + return reasoning_response +``` + +## Domain-Specific CoT + +### Math Problems + +```python +math_cot_template = """ +Problem: {problem} + +Solution: +Step 1: Identify what we know +- {list_known_values} + +Step 2: Identify what we need to find +- {target_variable} + +Step 3: Choose relevant formulas +- {formulas} + +Step 4: Substitute values +- {substitution} + +Step 5: Calculate +- {calculation} + +Step 6: Verify and state answer +- {verification} + +Answer: {final_answer} +""" +``` + +### Code Debugging + +```python +debug_cot_template = """ +Code with error: +{code} + +Error message: +{error} + +Debugging process: +Step 1: Understand the error message +- {interpret_error} + +Step 2: Locate the problematic line +- {identify_line} + +Step 3: Analyze why this line fails +- {root_cause} + +Step 4: Determine the fix +- {proposed_fix} + +Step 5: Verify the fix addresses the error +- {verification} + +Fixed code: +{corrected_code} +""" +``` + +### Logical Reasoning + +```python +logic_cot_template = """ +Premises: +{premises} + +Question: {question} + +Reasoning: +Step 1: List all given facts +{facts} + +Step 2: Identify logical relationships +{relationships} + +Step 3: Apply deductive reasoning +{deductions} + +Step 4: Draw conclusion +{conclusion} + +Answer: {final_answer} +""" +``` + +## Performance Optimization + +### Caching Reasoning Patterns + +```python +class ReasoningCache: + def __init__(self): + self.cache = {} + + def get_similar_reasoning(self, problem, threshold=0.85): + problem_embedding = embed(problem) + + for cached_problem, reasoning in self.cache.items(): + similarity = cosine_similarity( + problem_embedding, + embed(cached_problem) + ) + if similarity > threshold: + return reasoning + + return None + + def add_reasoning(self, problem, reasoning): + self.cache[problem] = reasoning +``` + +### Adaptive Reasoning Depth + +```python +def adaptive_cot(problem, initial_depth=3): + depth = initial_depth + + while depth <= 10: # Max depth + response = generate_cot(problem, num_steps=depth) + + # Check if solution seems complete + if is_solution_complete(response): + return response + + depth += 2 # Increase reasoning depth + + return response # Return best attempt +``` + +## Evaluation Metrics + +```python +def evaluate_cot_quality(reasoning_chain): + metrics = { + 'coherence': measure_logical_coherence(reasoning_chain), + 'completeness': check_all_steps_present(reasoning_chain), + 'correctness': verify_final_answer(reasoning_chain), + 'efficiency': count_unnecessary_steps(reasoning_chain), + 'clarity': rate_explanation_clarity(reasoning_chain) + } + return metrics +``` + +## Best Practices + +1. **Clear Step Markers**: Use numbered steps or clear delimiters +2. **Show All Work**: Don't skip steps, even obvious ones +3. **Verify Calculations**: Add explicit verification steps +4. **State Assumptions**: Make implicit assumptions explicit +5. **Check Edge Cases**: Consider boundary conditions +6. **Use Examples**: Show the reasoning pattern with examples first + +## Common Pitfalls + +- **Premature Conclusions**: Jumping to answer without full reasoning +- **Circular Logic**: Using the conclusion to justify the reasoning +- **Missing Steps**: Skipping intermediate calculations +- **Overcomplicated**: Adding unnecessary steps that confuse +- **Inconsistent Format**: Changing step structure mid-reasoning + +## When to Use CoT + +**Use CoT for:** + +- Math and arithmetic problems +- Logical reasoning tasks +- Multi-step planning +- Code generation and debugging +- Complex decision making + +**Skip CoT for:** + +- Simple factual queries +- Direct lookups +- Creative writing +- Tasks requiring conciseness +- Real-time, latency-sensitive applications + +## Resources + +- Benchmark datasets for CoT evaluation +- Pre-built CoT prompt templates +- Reasoning verification tools +- Step extraction and parsing utilities diff --git a/.cursor/skills/prompt-engineering-patterns/references/few-shot-learning.md b/.cursor/skills/prompt-engineering-patterns/references/few-shot-learning.md new file mode 100644 index 0000000..236eaa7 --- /dev/null +++ b/.cursor/skills/prompt-engineering-patterns/references/few-shot-learning.md @@ -0,0 +1,386 @@ +# Few-Shot Learning Guide + +## Overview + +Few-shot learning enables LLMs to perform tasks by providing a small number of examples (typically 1-10) within the prompt. This technique is highly effective for tasks requiring specific formats, styles, or domain knowledge. + +## Example Selection Strategies + +### 1. Semantic Similarity + +Select examples most similar to the input query using embedding-based retrieval. + +```python +from sentence_transformers import SentenceTransformer +import numpy as np + +class SemanticExampleSelector: + def __init__(self, examples, model_name='all-MiniLM-L6-v2'): + self.model = SentenceTransformer(model_name) + self.examples = examples + self.example_embeddings = self.model.encode([ex['input'] for ex in examples]) + + def select(self, query, k=3): + query_embedding = self.model.encode([query]) + similarities = np.dot(self.example_embeddings, query_embedding.T).flatten() + top_indices = np.argsort(similarities)[-k:][::-1] + return [self.examples[i] for i in top_indices] +``` + +**Best For**: Question answering, text classification, extraction tasks + +### 2. Diversity Sampling + +Maximize coverage of different patterns and edge cases. + +```python +from sklearn.cluster import KMeans + +class DiversityExampleSelector: + def __init__(self, examples, model_name='all-MiniLM-L6-v2'): + self.model = SentenceTransformer(model_name) + self.examples = examples + self.embeddings = self.model.encode([ex['input'] for ex in examples]) + + def select(self, k=5): + # Use k-means to find diverse cluster centers + kmeans = KMeans(n_clusters=k, random_state=42) + kmeans.fit(self.embeddings) + + # Select example closest to each cluster center + diverse_examples = [] + for center in kmeans.cluster_centers_: + distances = np.linalg.norm(self.embeddings - center, axis=1) + closest_idx = np.argmin(distances) + diverse_examples.append(self.examples[closest_idx]) + + return diverse_examples +``` + +**Best For**: Demonstrating task variability, edge case handling + +### 3. Difficulty-Based Selection + +Gradually increase example complexity to scaffold learning. + +```python +class ProgressiveExampleSelector: + def __init__(self, examples): + # Examples should have 'difficulty' scores (0-1) + self.examples = sorted(examples, key=lambda x: x['difficulty']) + + def select(self, k=3): + # Select examples with linearly increasing difficulty + step = len(self.examples) // k + return [self.examples[i * step] for i in range(k)] +``` + +**Best For**: Complex reasoning tasks, code generation + +### 4. Error-Based Selection + +Include examples that address common failure modes. + +```python +class ErrorGuidedSelector: + def __init__(self, examples, error_patterns): + self.examples = examples + self.error_patterns = error_patterns # Common mistakes to avoid + + def select(self, query, k=3): + # Select examples demonstrating correct handling of error patterns + selected = [] + for pattern in self.error_patterns[:k]: + matching = [ex for ex in self.examples if pattern in ex['demonstrates']] + if matching: + selected.append(matching[0]) + return selected +``` + +**Best For**: Tasks with known failure patterns, safety-critical applications + +## Example Construction Best Practices + +### Format Consistency + +All examples should follow identical formatting: + +```python +# Good: Consistent format +examples = [ + { + "input": "What is the capital of France?", + "output": "Paris" + }, + { + "input": "What is the capital of Germany?", + "output": "Berlin" + } +] + +# Bad: Inconsistent format +examples = [ + "Q: What is the capital of France? A: Paris", + {"question": "What is the capital of Germany?", "answer": "Berlin"} +] +``` + +### Input-Output Alignment + +Ensure examples demonstrate the exact task you want the model to perform: + +```python +# Good: Clear input-output relationship +example = { + "input": "Sentiment: The movie was terrible and boring.", + "output": "Negative" +} + +# Bad: Ambiguous relationship +example = { + "input": "The movie was terrible and boring.", + "output": "This review expresses negative sentiment toward the film." +} +``` + +### Complexity Balance + +Include examples spanning the expected difficulty range: + +```python +examples = [ + # Simple case + {"input": "2 + 2", "output": "4"}, + + # Moderate case + {"input": "15 * 3 + 8", "output": "53"}, + + # Complex case + {"input": "(12 + 8) * 3 - 15 / 5", "output": "57"} +] +``` + +## Context Window Management + +### Token Budget Allocation + +Typical distribution for a 4K context window: + +``` +System Prompt: 500 tokens (12%) +Few-Shot Examples: 1500 tokens (38%) +User Input: 500 tokens (12%) +Response: 1500 tokens (38%) +``` + +### Dynamic Example Truncation + +```python +class TokenAwareSelector: + def __init__(self, examples, tokenizer, max_tokens=1500): + self.examples = examples + self.tokenizer = tokenizer + self.max_tokens = max_tokens + + def select(self, query, k=5): + selected = [] + total_tokens = 0 + + # Start with most relevant examples + candidates = self.rank_by_relevance(query) + + for example in candidates[:k]: + example_tokens = len(self.tokenizer.encode( + f"Input: {example['input']}\nOutput: {example['output']}\n\n" + )) + + if total_tokens + example_tokens <= self.max_tokens: + selected.append(example) + total_tokens += example_tokens + else: + break + + return selected +``` + +## Edge Case Handling + +### Include Boundary Examples + +```python +edge_case_examples = [ + # Empty input + {"input": "", "output": "Please provide input text."}, + + # Very long input (truncated in example) + {"input": "..." + "word " * 1000, "output": "Input exceeds maximum length."}, + + # Ambiguous input + {"input": "bank", "output": "Ambiguous: Could refer to financial institution or river bank."}, + + # Invalid input + {"input": "!@#$%", "output": "Invalid input format. Please provide valid text."} +] +``` + +## Few-Shot Prompt Templates + +### Classification Template + +```python +def build_classification_prompt(examples, query, labels): + prompt = f"Classify the text into one of these categories: {', '.join(labels)}\n\n" + + for ex in examples: + prompt += f"Text: {ex['input']}\nCategory: {ex['output']}\n\n" + + prompt += f"Text: {query}\nCategory:" + return prompt +``` + +### Extraction Template + +```python +def build_extraction_prompt(examples, query): + prompt = "Extract structured information from the text.\n\n" + + for ex in examples: + prompt += f"Text: {ex['input']}\nExtracted: {json.dumps(ex['output'])}\n\n" + + prompt += f"Text: {query}\nExtracted:" + return prompt +``` + +### Transformation Template + +```python +def build_transformation_prompt(examples, query): + prompt = "Transform the input according to the pattern shown in examples.\n\n" + + for ex in examples: + prompt += f"Input: {ex['input']}\nOutput: {ex['output']}\n\n" + + prompt += f"Input: {query}\nOutput:" + return prompt +``` + +## Evaluation and Optimization + +### Example Quality Metrics + +```python +def evaluate_example_quality(example, validation_set): + metrics = { + 'clarity': rate_clarity(example), # 0-1 score + 'representativeness': calculate_similarity_to_validation(example, validation_set), + 'difficulty': estimate_difficulty(example), + 'uniqueness': calculate_uniqueness(example, other_examples) + } + return metrics +``` + +### A/B Testing Example Sets + +```python +class ExampleSetTester: + def __init__(self, llm_client): + self.client = llm_client + + def compare_example_sets(self, set_a, set_b, test_queries): + results_a = self.evaluate_set(set_a, test_queries) + results_b = self.evaluate_set(set_b, test_queries) + + return { + 'set_a_accuracy': results_a['accuracy'], + 'set_b_accuracy': results_b['accuracy'], + 'winner': 'A' if results_a['accuracy'] > results_b['accuracy'] else 'B', + 'improvement': abs(results_a['accuracy'] - results_b['accuracy']) + } + + def evaluate_set(self, examples, test_queries): + correct = 0 + for query in test_queries: + prompt = build_prompt(examples, query['input']) + response = self.client.complete(prompt) + if response == query['expected_output']: + correct += 1 + return {'accuracy': correct / len(test_queries)} +``` + +## Advanced Techniques + +### Meta-Learning (Learning to Select) + +Train a small model to predict which examples will be most effective: + +```python +from sklearn.ensemble import RandomForestClassifier + +class LearnedExampleSelector: + def __init__(self): + self.selector_model = RandomForestClassifier() + + def train(self, training_data): + # training_data: list of (query, example, success) tuples + features = [] + labels = [] + + for query, example, success in training_data: + features.append(self.extract_features(query, example)) + labels.append(1 if success else 0) + + self.selector_model.fit(features, labels) + + def extract_features(self, query, example): + return [ + semantic_similarity(query, example['input']), + len(example['input']), + len(example['output']), + keyword_overlap(query, example['input']) + ] + + def select(self, query, candidates, k=3): + scores = [] + for example in candidates: + features = self.extract_features(query, example) + score = self.selector_model.predict_proba([features])[0][1] + scores.append((score, example)) + + return [ex for _, ex in sorted(scores, reverse=True)[:k]] +``` + +### Adaptive Example Count + +Dynamically adjust the number of examples based on task difficulty: + +```python +class AdaptiveExampleSelector: + def __init__(self, examples): + self.examples = examples + + def select(self, query, max_examples=5): + # Start with 1 example + for k in range(1, max_examples + 1): + selected = self.get_top_k(query, k) + + # Quick confidence check (could use a lightweight model) + if self.estimated_confidence(query, selected) > 0.9: + return selected + + return selected # Return max_examples if never confident enough +``` + +## Common Mistakes + +1. **Too Many Examples**: More isn't always better; can dilute focus +2. **Irrelevant Examples**: Examples should match the target task closely +3. **Inconsistent Formatting**: Confuses the model about output format +4. **Overfitting to Examples**: Model copies example patterns too literally +5. **Ignoring Token Limits**: Running out of space for actual input/output + +## Resources + +- Example dataset repositories +- Pre-built example selectors for common tasks +- Evaluation frameworks for few-shot performance +- Token counting utilities for different models diff --git a/.cursor/skills/prompt-engineering-patterns/references/prompt-optimization.md b/.cursor/skills/prompt-engineering-patterns/references/prompt-optimization.md new file mode 100644 index 0000000..6b3ee7e --- /dev/null +++ b/.cursor/skills/prompt-engineering-patterns/references/prompt-optimization.md @@ -0,0 +1,428 @@ +# Prompt Optimization Guide + +## Systematic Refinement Process + +### 1. Baseline Establishment + +```python +def establish_baseline(prompt, test_cases): + results = { + 'accuracy': 0, + 'avg_tokens': 0, + 'avg_latency': 0, + 'success_rate': 0 + } + + for test_case in test_cases: + response = llm.complete(prompt.format(**test_case['input'])) + + results['accuracy'] += evaluate_accuracy(response, test_case['expected']) + results['avg_tokens'] += count_tokens(response) + results['avg_latency'] += measure_latency(response) + results['success_rate'] += is_valid_response(response) + + # Average across test cases + n = len(test_cases) + return {k: v/n for k, v in results.items()} +``` + +### 2. Iterative Refinement Workflow + +``` +Initial Prompt → Test → Analyze Failures → Refine → Test → Repeat +``` + +```python +class PromptOptimizer: + def __init__(self, initial_prompt, test_suite): + self.prompt = initial_prompt + self.test_suite = test_suite + self.history = [] + + def optimize(self, max_iterations=10): + for i in range(max_iterations): + # Test current prompt + results = self.evaluate_prompt(self.prompt) + self.history.append({ + 'iteration': i, + 'prompt': self.prompt, + 'results': results + }) + + # Stop if good enough + if results['accuracy'] > 0.95: + break + + # Analyze failures + failures = self.analyze_failures(results) + + # Generate refinement suggestions + refinements = self.generate_refinements(failures) + + # Apply best refinement + self.prompt = self.select_best_refinement(refinements) + + return self.get_best_prompt() +``` + +### 3. A/B Testing Framework + +```python +class PromptABTest: + def __init__(self, variant_a, variant_b): + self.variant_a = variant_a + self.variant_b = variant_b + + def run_test(self, test_queries, metrics=['accuracy', 'latency']): + results = { + 'A': {m: [] for m in metrics}, + 'B': {m: [] for m in metrics} + } + + for query in test_queries: + # Randomly assign variant (50/50 split) + variant = 'A' if random.random() < 0.5 else 'B' + prompt = self.variant_a if variant == 'A' else self.variant_b + + response, metrics_data = self.execute_with_metrics( + prompt.format(query=query['input']) + ) + + for metric in metrics: + results[variant][metric].append(metrics_data[metric]) + + return self.analyze_results(results) + + def analyze_results(self, results): + from scipy import stats + + analysis = {} + for metric in results['A'].keys(): + a_values = results['A'][metric] + b_values = results['B'][metric] + + # Statistical significance test + t_stat, p_value = stats.ttest_ind(a_values, b_values) + + analysis[metric] = { + 'A_mean': np.mean(a_values), + 'B_mean': np.mean(b_values), + 'improvement': (np.mean(b_values) - np.mean(a_values)) / np.mean(a_values), + 'statistically_significant': p_value < 0.05, + 'p_value': p_value, + 'winner': 'B' if np.mean(b_values) > np.mean(a_values) else 'A' + } + + return analysis +``` + +## Optimization Strategies + +### Token Reduction + +```python +def optimize_for_tokens(prompt): + optimizations = [ + # Remove redundant phrases + ('in order to', 'to'), + ('due to the fact that', 'because'), + ('at this point in time', 'now'), + + # Consolidate instructions + ('First, ...\\nThen, ...\\nFinally, ...', 'Steps: 1) ... 2) ... 3) ...'), + + # Use abbreviations (after first definition) + ('Natural Language Processing (NLP)', 'NLP'), + + # Remove filler words + (' actually ', ' '), + (' basically ', ' '), + (' really ', ' ') + ] + + optimized = prompt + for old, new in optimizations: + optimized = optimized.replace(old, new) + + return optimized +``` + +### Latency Reduction + +```python +def optimize_for_latency(prompt): + strategies = { + 'shorter_prompt': reduce_token_count(prompt), + 'streaming': enable_streaming_response(prompt), + 'caching': add_cacheable_prefix(prompt), + 'early_stopping': add_stop_sequences(prompt) + } + + # Test each strategy + best_strategy = None + best_latency = float('inf') + + for name, modified_prompt in strategies.items(): + latency = measure_average_latency(modified_prompt) + if latency < best_latency: + best_latency = latency + best_strategy = modified_prompt + + return best_strategy +``` + +### Accuracy Improvement + +```python +def improve_accuracy(prompt, failure_cases): + improvements = [] + + # Add constraints for common failures + if has_format_errors(failure_cases): + improvements.append("Output must be valid JSON with no additional text.") + + # Add examples for edge cases + edge_cases = identify_edge_cases(failure_cases) + if edge_cases: + improvements.append(f"Examples of edge cases:\\n{format_examples(edge_cases)}") + + # Add verification step + if has_logical_errors(failure_cases): + improvements.append("Before responding, verify your answer is logically consistent.") + + # Strengthen instructions + if has_ambiguity_errors(failure_cases): + improvements.append(clarify_ambiguous_instructions(prompt)) + + return integrate_improvements(prompt, improvements) +``` + +## Performance Metrics + +### Core Metrics + +```python +class PromptMetrics: + @staticmethod + def accuracy(responses, ground_truth): + return sum(r == gt for r, gt in zip(responses, ground_truth)) / len(responses) + + @staticmethod + def consistency(responses): + # Measure how often identical inputs produce identical outputs + from collections import defaultdict + input_responses = defaultdict(list) + + for inp, resp in responses: + input_responses[inp].append(resp) + + consistency_scores = [] + for inp, resps in input_responses.items(): + if len(resps) > 1: + # Percentage of responses that match the most common response + most_common_count = Counter(resps).most_common(1)[0][1] + consistency_scores.append(most_common_count / len(resps)) + + return np.mean(consistency_scores) if consistency_scores else 1.0 + + @staticmethod + def token_efficiency(prompt, responses): + avg_prompt_tokens = np.mean([count_tokens(prompt.format(**r['input'])) for r in responses]) + avg_response_tokens = np.mean([count_tokens(r['output']) for r in responses]) + return avg_prompt_tokens + avg_response_tokens + + @staticmethod + def latency_p95(latencies): + return np.percentile(latencies, 95) +``` + +### Automated Evaluation + +```python +def evaluate_prompt_comprehensively(prompt, test_suite): + results = { + 'accuracy': [], + 'consistency': [], + 'latency': [], + 'tokens': [], + 'success_rate': [] + } + + # Run each test case multiple times for consistency measurement + for test_case in test_suite: + runs = [] + for _ in range(3): # 3 runs per test case + start = time.time() + response = llm.complete(prompt.format(**test_case['input'])) + latency = time.time() - start + + runs.append(response) + results['latency'].append(latency) + results['tokens'].append(count_tokens(prompt) + count_tokens(response)) + + # Accuracy (best of 3 runs) + accuracies = [evaluate_accuracy(r, test_case['expected']) for r in runs] + results['accuracy'].append(max(accuracies)) + + # Consistency (how similar are the 3 runs?) + results['consistency'].append(calculate_similarity(runs)) + + # Success rate (all runs successful?) + results['success_rate'].append(all(is_valid(r) for r in runs)) + + return { + 'avg_accuracy': np.mean(results['accuracy']), + 'avg_consistency': np.mean(results['consistency']), + 'p95_latency': np.percentile(results['latency'], 95), + 'avg_tokens': np.mean(results['tokens']), + 'success_rate': np.mean(results['success_rate']) + } +``` + +## Failure Analysis + +### Categorizing Failures + +```python +class FailureAnalyzer: + def categorize_failures(self, test_results): + categories = { + 'format_errors': [], + 'factual_errors': [], + 'logic_errors': [], + 'incomplete_responses': [], + 'hallucinations': [], + 'off_topic': [] + } + + for result in test_results: + if not result['success']: + category = self.determine_failure_type( + result['response'], + result['expected'] + ) + categories[category].append(result) + + return categories + + def generate_fixes(self, categorized_failures): + fixes = [] + + if categorized_failures['format_errors']: + fixes.append({ + 'issue': 'Format errors', + 'fix': 'Add explicit format examples and constraints', + 'priority': 'high' + }) + + if categorized_failures['hallucinations']: + fixes.append({ + 'issue': 'Hallucinations', + 'fix': 'Add grounding instruction: "Base your answer only on provided context"', + 'priority': 'critical' + }) + + if categorized_failures['incomplete_responses']: + fixes.append({ + 'issue': 'Incomplete responses', + 'fix': 'Add: "Ensure your response fully addresses all parts of the question"', + 'priority': 'medium' + }) + + return fixes +``` + +## Versioning and Rollback + +### Prompt Version Control + +```python +class PromptVersionControl: + def __init__(self, storage_path): + self.storage = storage_path + self.versions = [] + + def save_version(self, prompt, metadata): + version = { + 'id': len(self.versions), + 'prompt': prompt, + 'timestamp': datetime.now(), + 'metrics': metadata.get('metrics', {}), + 'description': metadata.get('description', ''), + 'parent_id': metadata.get('parent_id') + } + self.versions.append(version) + self.persist() + return version['id'] + + def rollback(self, version_id): + if version_id < len(self.versions): + return self.versions[version_id]['prompt'] + raise ValueError(f"Version {version_id} not found") + + def compare_versions(self, v1_id, v2_id): + v1 = self.versions[v1_id] + v2 = self.versions[v2_id] + + return { + 'diff': generate_diff(v1['prompt'], v2['prompt']), + 'metrics_comparison': { + metric: { + 'v1': v1['metrics'].get(metric), + 'v2': v2['metrics'].get(metric'), + 'change': v2['metrics'].get(metric, 0) - v1['metrics'].get(metric, 0) + } + for metric in set(v1['metrics'].keys()) | set(v2['metrics'].keys()) + } + } +``` + +## Best Practices + +1. **Establish Baseline**: Always measure initial performance +2. **Change One Thing**: Isolate variables for clear attribution +3. **Test Thoroughly**: Use diverse, representative test cases +4. **Track Metrics**: Log all experiments and results +5. **Validate Significance**: Use statistical tests for A/B comparisons +6. **Document Changes**: Keep detailed notes on what and why +7. **Version Everything**: Enable rollback to previous versions +8. **Monitor Production**: Continuously evaluate deployed prompts + +## Common Optimization Patterns + +### Pattern 1: Add Structure + +``` +Before: "Analyze this text" +After: "Analyze this text for:\n1. Main topic\n2. Key arguments\n3. Conclusion" +``` + +### Pattern 2: Add Examples + +``` +Before: "Extract entities" +After: "Extract entities\\n\\nExample:\\nText: Apple released iPhone\\nEntities: {company: Apple, product: iPhone}" +``` + +### Pattern 3: Add Constraints + +``` +Before: "Summarize this" +After: "Summarize in exactly 3 bullet points, 15 words each" +``` + +### Pattern 4: Add Verification + +``` +Before: "Calculate..." +After: "Calculate... Then verify your calculation is correct before responding." +``` + +## Tools and Utilities + +- Prompt diff tools for version comparison +- Automated test runners +- Metric dashboards +- A/B testing frameworks +- Token counting utilities +- Latency profilers diff --git a/.cursor/skills/prompt-engineering-patterns/references/prompt-templates.md b/.cursor/skills/prompt-engineering-patterns/references/prompt-templates.md new file mode 100644 index 0000000..e2e7911 --- /dev/null +++ b/.cursor/skills/prompt-engineering-patterns/references/prompt-templates.md @@ -0,0 +1,484 @@ +# Prompt Template Systems + +## Template Architecture + +### Basic Template Structure + +```python +class PromptTemplate: + def __init__(self, template_string, variables=None): + self.template = template_string + self.variables = variables or [] + + def render(self, **kwargs): + missing = set(self.variables) - set(kwargs.keys()) + if missing: + raise ValueError(f"Missing required variables: {missing}") + + return self.template.format(**kwargs) + +# Usage +template = PromptTemplate( + template_string="Translate {text} from {source_lang} to {target_lang}", + variables=['text', 'source_lang', 'target_lang'] +) + +prompt = template.render( + text="Hello world", + source_lang="English", + target_lang="Spanish" +) +``` + +### Conditional Templates + +```python +class ConditionalTemplate(PromptTemplate): + def render(self, **kwargs): + # Process conditional blocks + result = self.template + + # Handle if-blocks: {{#if variable}}content{{/if}} + import re + if_pattern = r'\{\{#if (\w+)\}\}(.*?)\{\{/if\}\}' + + def replace_if(match): + var_name = match.group(1) + content = match.group(2) + return content if kwargs.get(var_name) else '' + + result = re.sub(if_pattern, replace_if, result, flags=re.DOTALL) + + # Handle for-loops: {{#each items}}{{this}}{{/each}} + each_pattern = r'\{\{#each (\w+)\}\}(.*?)\{\{/each\}\}' + + def replace_each(match): + var_name = match.group(1) + content = match.group(2) + items = kwargs.get(var_name, []) + return '\\n'.join(content.replace('{{this}}', str(item)) for item in items) + + result = re.sub(each_pattern, replace_each, result, flags=re.DOTALL) + + # Finally, render remaining variables + return result.format(**kwargs) + +# Usage +template = ConditionalTemplate(""" +Analyze the following text: +{text} + +{{#if include_sentiment}} +Provide sentiment analysis. +{{/if}} + +{{#if include_entities}} +Extract named entities. +{{/if}} + +{{#if examples}} +Reference examples: +{{#each examples}} +- {{this}} +{{/each}} +{{/if}} +""") +``` + +### Modular Template Composition + +```python +class ModularTemplate: + def __init__(self): + self.components = {} + + def register_component(self, name, template): + self.components[name] = template + + def render(self, structure, **kwargs): + parts = [] + for component_name in structure: + if component_name in self.components: + component = self.components[component_name] + parts.append(component.format(**kwargs)) + + return '\\n\\n'.join(parts) + +# Usage +builder = ModularTemplate() + +builder.register_component('system', "You are a {role}.") +builder.register_component('context', "Context: {context}") +builder.register_component('instruction', "Task: {task}") +builder.register_component('examples', "Examples:\\n{examples}") +builder.register_component('input', "Input: {input}") +builder.register_component('format', "Output format: {format}") + +# Compose different templates for different scenarios +basic_prompt = builder.render( + ['system', 'instruction', 'input'], + role='helpful assistant', + instruction='Summarize the text', + input='...' +) + +advanced_prompt = builder.render( + ['system', 'context', 'examples', 'instruction', 'input', 'format'], + role='expert analyst', + context='Financial analysis', + examples='...', + instruction='Analyze sentiment', + input='...', + format='JSON' +) +``` + +## Common Template Patterns + +### Classification Template + +```python +CLASSIFICATION_TEMPLATE = """ +Classify the following {content_type} into one of these categories: {categories} + +{{#if description}} +Category descriptions: +{description} +{{/if}} + +{{#if examples}} +Examples: +{examples} +{{/if}} + +{content_type}: {input} + +Category:""" +``` + +### Extraction Template + +```python +EXTRACTION_TEMPLATE = """ +Extract structured information from the {content_type}. + +Required fields: +{field_definitions} + +{{#if examples}} +Example extraction: +{examples} +{{/if}} + +{content_type}: {input} + +Extracted information (JSON):""" +``` + +### Generation Template + +```python +GENERATION_TEMPLATE = """ +Generate {output_type} based on the following {input_type}. + +Requirements: +{requirements} + +{{#if style}} +Style: {style} +{{/if}} + +{{#if constraints}} +Constraints: +{constraints} +{{/if}} + +{{#if examples}} +Examples: +{examples} +{{/if}} + +{input_type}: {input} + +{output_type}:""" +``` + +### Transformation Template + +```python +TRANSFORMATION_TEMPLATE = """ +Transform the input {source_format} to {target_format}. + +Transformation rules: +{rules} + +{{#if examples}} +Example transformations: +{examples} +{{/if}} + +Input {source_format}: +{input} + +Output {target_format}:""" +``` + +## Advanced Features + +### Template Inheritance + +```python +class TemplateRegistry: + def __init__(self): + self.templates = {} + + def register(self, name, template, parent=None): + if parent and parent in self.templates: + # Inherit from parent + base = self.templates[parent] + template = self.merge_templates(base, template) + + self.templates[name] = template + + def merge_templates(self, parent, child): + # Child overwrites parent sections + return {**parent, **child} + +# Usage +registry = TemplateRegistry() + +registry.register('base_analysis', { + 'system': 'You are an expert analyst.', + 'format': 'Provide analysis in structured format.' +}) + +registry.register('sentiment_analysis', { + 'instruction': 'Analyze sentiment', + 'format': 'Provide sentiment score from -1 to 1.' +}, parent='base_analysis') +``` + +### Variable Validation + +```python +class ValidatedTemplate: + def __init__(self, template, schema): + self.template = template + self.schema = schema + + def validate_vars(self, **kwargs): + for var_name, var_schema in self.schema.items(): + if var_name in kwargs: + value = kwargs[var_name] + + # Type validation + if 'type' in var_schema: + expected_type = var_schema['type'] + if not isinstance(value, expected_type): + raise TypeError(f"{var_name} must be {expected_type}") + + # Range validation + if 'min' in var_schema and value < var_schema['min']: + raise ValueError(f"{var_name} must be >= {var_schema['min']}") + + if 'max' in var_schema and value > var_schema['max']: + raise ValueError(f"{var_name} must be <= {var_schema['max']}") + + # Enum validation + if 'choices' in var_schema and value not in var_schema['choices']: + raise ValueError(f"{var_name} must be one of {var_schema['choices']}") + + def render(self, **kwargs): + self.validate_vars(**kwargs) + return self.template.format(**kwargs) + +# Usage +template = ValidatedTemplate( + template="Summarize in {length} words with {tone} tone", + schema={ + 'length': {'type': int, 'min': 10, 'max': 500}, + 'tone': {'type': str, 'choices': ['formal', 'casual', 'technical']} + } +) +``` + +### Template Caching + +```python +class CachedTemplate: + def __init__(self, template): + self.template = template + self.cache = {} + + def render(self, use_cache=True, **kwargs): + if use_cache: + cache_key = self.get_cache_key(kwargs) + if cache_key in self.cache: + return self.cache[cache_key] + + result = self.template.format(**kwargs) + + if use_cache: + self.cache[cache_key] = result + + return result + + def get_cache_key(self, kwargs): + return hash(frozenset(kwargs.items())) + + def clear_cache(self): + self.cache = {} +``` + +## Multi-Turn Templates + +### Conversation Template + +```python +class ConversationTemplate: + def __init__(self, system_prompt): + self.system_prompt = system_prompt + self.history = [] + + def add_user_message(self, message): + self.history.append({'role': 'user', 'content': message}) + + def add_assistant_message(self, message): + self.history.append({'role': 'assistant', 'content': message}) + + def render_for_api(self): + messages = [{'role': 'system', 'content': self.system_prompt}] + messages.extend(self.history) + return messages + + def render_as_text(self): + result = f"System: {self.system_prompt}\\n\\n" + for msg in self.history: + role = msg['role'].capitalize() + result += f"{role}: {msg['content']}\\n\\n" + return result +``` + +### State-Based Templates + +```python +class StatefulTemplate: + def __init__(self): + self.state = {} + self.templates = {} + + def set_state(self, **kwargs): + self.state.update(kwargs) + + def register_state_template(self, state_name, template): + self.templates[state_name] = template + + def render(self): + current_state = self.state.get('current_state', 'default') + template = self.templates.get(current_state) + + if not template: + raise ValueError(f"No template for state: {current_state}") + + return template.format(**self.state) + +# Usage for multi-step workflows +workflow = StatefulTemplate() + +workflow.register_state_template('init', """ +Welcome! Let's {task}. +What is your {first_input}? +""") + +workflow.register_state_template('processing', """ +Thanks! Processing {first_input}. +Now, what is your {second_input}? +""") + +workflow.register_state_template('complete', """ +Great! Based on: +- {first_input} +- {second_input} + +Here's the result: {result} +""") +``` + +## Best Practices + +1. **Keep It DRY**: Use templates to avoid repetition +2. **Validate Early**: Check variables before rendering +3. **Version Templates**: Track changes like code +4. **Test Variations**: Ensure templates work with diverse inputs +5. **Document Variables**: Clearly specify required/optional variables +6. **Use Type Hints**: Make variable types explicit +7. **Provide Defaults**: Set sensible default values where appropriate +8. **Cache Wisely**: Cache static templates, not dynamic ones + +## Template Libraries + +### Question Answering + +```python +QA_TEMPLATES = { + 'factual': """Answer the question based on the context. + +Context: {context} +Question: {question} +Answer:""", + + 'multi_hop': """Answer the question by reasoning across multiple facts. + +Facts: {facts} +Question: {question} + +Reasoning:""", + + 'conversational': """Continue the conversation naturally. + +Previous conversation: +{history} + +User: {question} +Assistant:""" +} +``` + +### Content Generation + +```python +GENERATION_TEMPLATES = { + 'blog_post': """Write a blog post about {topic}. + +Requirements: +- Length: {word_count} words +- Tone: {tone} +- Include: {key_points} + +Blog post:""", + + 'product_description': """Write a product description for {product}. + +Features: {features} +Benefits: {benefits} +Target audience: {audience} + +Description:""", + + 'email': """Write a {type} email. + +To: {recipient} +Context: {context} +Key points: {key_points} + +Email:""" +} +``` + +## Performance Considerations + +- Pre-compile templates for repeated use +- Cache rendered templates when variables are static +- Minimize string concatenation in loops +- Use efficient string formatting (f-strings, .format()) +- Profile template rendering for bottlenecks diff --git a/.cursor/skills/prompt-engineering-patterns/scripts/optimize-prompt.py b/.cursor/skills/prompt-engineering-patterns/scripts/optimize-prompt.py new file mode 100644 index 0000000..5357b6c --- /dev/null +++ b/.cursor/skills/prompt-engineering-patterns/scripts/optimize-prompt.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +""" +Prompt Optimization Script + +Automatically test and optimize prompts using A/B testing and metrics tracking. +""" + +import json +import time +from typing import List, Dict, Any +from dataclasses import dataclass +from concurrent.futures import ThreadPoolExecutor +import numpy as np + + +@dataclass +class TestCase: + input: Dict[str, Any] + expected_output: str + metadata: Dict[str, Any] = None + + +class PromptOptimizer: + def __init__(self, llm_client, test_suite: List[TestCase]): + self.client = llm_client + self.test_suite = test_suite + self.results_history = [] + self.executor = ThreadPoolExecutor() + + def shutdown(self): + """Shutdown the thread pool executor.""" + self.executor.shutdown(wait=True) + + def evaluate_prompt(self, prompt_template: str, test_cases: List[TestCase] = None) -> Dict[str, float]: + """Evaluate a prompt template against test cases in parallel.""" + if test_cases is None: + test_cases = self.test_suite + + metrics = { + 'accuracy': [], + 'latency': [], + 'token_count': [], + 'success_rate': [] + } + + def process_test_case(test_case): + start_time = time.time() + + # Render prompt with test case inputs + prompt = prompt_template.format(**test_case.input) + + # Get LLM response + response = self.client.complete(prompt) + + # Measure latency + latency = time.time() - start_time + + # Calculate individual metrics + token_count = len(prompt.split()) + len(response.split()) + success = 1 if response else 0 + accuracy = self.calculate_accuracy(response, test_case.expected_output) + + return { + 'latency': latency, + 'token_count': token_count, + 'success_rate': success, + 'accuracy': accuracy + } + + # Run test cases in parallel + results = list(self.executor.map(process_test_case, test_cases)) + + # Aggregate metrics + for result in results: + metrics['latency'].append(result['latency']) + metrics['token_count'].append(result['token_count']) + metrics['success_rate'].append(result['success_rate']) + metrics['accuracy'].append(result['accuracy']) + + return { + 'avg_accuracy': np.mean(metrics['accuracy']), + 'avg_latency': np.mean(metrics['latency']), + 'p95_latency': np.percentile(metrics['latency'], 95), + 'avg_tokens': np.mean(metrics['token_count']), + 'success_rate': np.mean(metrics['success_rate']) + } + + def calculate_accuracy(self, response: str, expected: str) -> float: + """Calculate accuracy score between response and expected output.""" + # Simple exact match + if response.strip().lower() == expected.strip().lower(): + return 1.0 + + # Partial match using word overlap + response_words = set(response.lower().split()) + expected_words = set(expected.lower().split()) + + if not expected_words: + return 0.0 + + overlap = len(response_words & expected_words) + return overlap / len(expected_words) + + def optimize(self, base_prompt: str, max_iterations: int = 5) -> Dict[str, Any]: + """Iteratively optimize a prompt.""" + current_prompt = base_prompt + best_prompt = base_prompt + best_score = 0 + current_metrics = None + + for iteration in range(max_iterations): + print(f"\nIteration {iteration + 1}/{max_iterations}") + + # Evaluate current prompt + # Bolt Optimization: Avoid re-evaluating if we already have metrics from previous iteration + if current_metrics: + metrics = current_metrics + else: + metrics = self.evaluate_prompt(current_prompt) + + print(f"Accuracy: {metrics['avg_accuracy']:.2f}, Latency: {metrics['avg_latency']:.2f}s") + + # Track results + self.results_history.append({ + 'iteration': iteration, + 'prompt': current_prompt, + 'metrics': metrics + }) + + # Update best if improved + if metrics['avg_accuracy'] > best_score: + best_score = metrics['avg_accuracy'] + best_prompt = current_prompt + + # Stop if good enough + if metrics['avg_accuracy'] > 0.95: + print("Achieved target accuracy!") + break + + # Generate variations for next iteration + variations = self.generate_variations(current_prompt, metrics) + + # Test variations and pick best + best_variation = current_prompt + best_variation_score = metrics['avg_accuracy'] + best_variation_metrics = metrics + + for variation in variations: + var_metrics = self.evaluate_prompt(variation) + if var_metrics['avg_accuracy'] > best_variation_score: + best_variation_score = var_metrics['avg_accuracy'] + best_variation = variation + best_variation_metrics = var_metrics + + current_prompt = best_variation + current_metrics = best_variation_metrics + + return { + 'best_prompt': best_prompt, + 'best_score': best_score, + 'history': self.results_history + } + + def generate_variations(self, prompt: str, current_metrics: Dict) -> List[str]: + """Generate prompt variations to test.""" + variations = [] + + # Variation 1: Add explicit format instruction + variations.append(prompt + "\n\nProvide your answer in a clear, concise format.") + + # Variation 2: Add step-by-step instruction + variations.append("Let's solve this step by step.\n\n" + prompt) + + # Variation 3: Add verification step + variations.append(prompt + "\n\nVerify your answer before responding.") + + # Variation 4: Make more concise + concise = self.make_concise(prompt) + if concise != prompt: + variations.append(concise) + + # Variation 5: Add examples (if none present) + if "example" not in prompt.lower(): + variations.append(self.add_examples(prompt)) + + return variations[:3] # Return top 3 variations + + def make_concise(self, prompt: str) -> str: + """Remove redundant words to make prompt more concise.""" + replacements = [ + ("in order to", "to"), + ("due to the fact that", "because"), + ("at this point in time", "now"), + ("in the event that", "if"), + ] + + result = prompt + for old, new in replacements: + result = result.replace(old, new) + + return result + + def add_examples(self, prompt: str) -> str: + """Add example section to prompt.""" + return f"""{prompt} + +Example: +Input: Sample input +Output: Sample output +""" + + def compare_prompts(self, prompt_a: str, prompt_b: str) -> Dict[str, Any]: + """A/B test two prompts.""" + print("Testing Prompt A...") + metrics_a = self.evaluate_prompt(prompt_a) + + print("Testing Prompt B...") + metrics_b = self.evaluate_prompt(prompt_b) + + return { + 'prompt_a_metrics': metrics_a, + 'prompt_b_metrics': metrics_b, + 'winner': 'A' if metrics_a['avg_accuracy'] > metrics_b['avg_accuracy'] else 'B', + 'improvement': abs(metrics_a['avg_accuracy'] - metrics_b['avg_accuracy']) + } + + def export_results(self, filename: str): + """Export optimization results to JSON.""" + with open(filename, 'w') as f: + json.dump(self.results_history, f, indent=2) + + +def main(): + # Example usage + test_suite = [ + TestCase( + input={'text': 'This movie was amazing!'}, + expected_output='Positive' + ), + TestCase( + input={'text': 'Worst purchase ever.'}, + expected_output='Negative' + ), + TestCase( + input={'text': 'It was okay, nothing special.'}, + expected_output='Neutral' + ) + ] + + # Mock LLM client for demonstration + class MockLLMClient: + def complete(self, prompt): + # Simulate LLM response + if 'amazing' in prompt: + return 'Positive' + elif 'worst' in prompt.lower(): + return 'Negative' + else: + return 'Neutral' + + optimizer = PromptOptimizer(MockLLMClient(), test_suite) + + try: + base_prompt = "Classify the sentiment of: {text}\nSentiment:" + + results = optimizer.optimize(base_prompt) + + print("\n" + "="*50) + print("Optimization Complete!") + print(f"Best Accuracy: {results['best_score']:.2f}") + print(f"Best Prompt:\n{results['best_prompt']}") + + optimizer.export_results('optimization_results.json') + finally: + optimizer.shutdown() + + +if __name__ == '__main__': + main() diff --git a/.cursor/skills/writing-linkedin-posts/SKILL.md b/.cursor/skills/writing-linkedin-posts/SKILL.md new file mode 100644 index 0000000..19ec70a --- /dev/null +++ b/.cursor/skills/writing-linkedin-posts/SKILL.md @@ -0,0 +1,347 @@ +--- +name: writing-linkedin-posts +description: Create engaging, authentic LinkedIn posts like a Top Voice. Use this skill when asked to write LinkedIn content, social media posts for LinkedIn, professional thought leadership content, or help with LinkedIn engagement strategy. Triggers include requests for LinkedIn posts, professional social content, thought leadership pieces, or viral/engaging LinkedIn content. +--- + +# LinkedIn Post Writer + +Create engaging, authentic LinkedIn posts that drive meaningful engagement and establish thought leadership. + +## Core Principles + +### Authenticity Over Performance + +LinkedIn has matured. Readers can spot manufactured vulnerability and engagement bait instantly. The posts that resonate now are genuinely useful or genuinely human—not optimized for virality. + +What works: +- Real experiences with honest reflection +- Specific insights from your actual work +- Admitting what you don't know +- Sharing without needing validation + +What doesn't: +- Performed vulnerability for engagement +- Stories that feel too perfectly structured +- Lessons that sound like motivational posters + +### One Idea Per Post + +The biggest mistake is cramming multiple tips, stories, or angles into one post. Focus on: +- One core idea +- One story +- One insight +- One lesson + +If you have five points, that's five posts. + +### Value Without Strings + +Every post must educate, inspire, or entertain. Ask: "Would I find this valuable if a stranger posted it?" Not "Will this get engagement?" + +## Building Your Signature Voice + +Top Voices don't just post well—they're recognizable. Their perspective, style, and focus areas are consistent. + +### Define Your POV + +Answer these before writing: +- What topic do I have genuine expertise in? +- What's my contrarian belief in my industry? +- What perspective do I bring that others don't? +- What would I want to be known for saying? + +### Consistency Markers + +Develop 2-3 recognizable elements: +- A recurring theme or topic area +- A consistent tone (analytical, warm, direct, witty) +- A signature format you're known for +- Phrases or frameworks you've coined + +### Voice Calibration + +Your LinkedIn voice should be: +- More polished than a text to a friend +- Less formal than a company memo +- As smart as your best work conversations +- As human as your real personality + +## Post Anatomy + +### The Hook (Lines 1-2) + +The first 2-3 lines appear before "See more" and determine everything. + +**Modern hooks that work** (avoid overused patterns): + +| Type | Example | Why It Works | +|------|---------|--------------| +| **Honest admission** | "I've been wrong about remote work." | Genuine, not performed | +| **Specific observation** | "I've noticed something in every founder who scaled past $10M." | Credibility + curiosity | +| **Direct challenge** | "Most career advice optimizes for the wrong thing." | Provokes thought | +| **Unexpected angle** | "The best hire I made had the worst resume." | Subverts expectations | +| **Simple truth** | "Nobody talks about how lonely leadership is." | Resonates emotionally | + +**Hooks to retire**: +- "This one thing made me $X" (feels like a scam ad) +- "The CEO pulled me aside and said..." (overused curiosity bait) +- "I'm excited to announce..." (corporate and skippable) +- "[Number] words that changed my life" (too formulaic) + +See `references/hooks.md` for comprehensive examples. + +### The Body + +Structure for scannability: +- Short paragraphs (1-3 sentences max) +- Line breaks between thoughts +- Bullet points for lists (but don't overuse) +- Bold for emphasis sparingly + +**Storytelling framework**: +1. Set the scene (briefly—one sentence) +2. Introduce tension or conflict +3. Show the turning point +4. Deliver the insight +5. Make it applicable to the reader + +### The Close + +End with purpose, not desperation: + +**Good closes**: +- Genuine question that invites perspective +- Specific call to action ("Bookmark this for your next negotiation") +- Reflection that lingers ("Something to sit with this week") + +**Avoid**: +- "Agree?" (lazy) +- "Comment YES if..." (engagement bait) +- "Share this with someone who needs it" (needy) +- "Follow me for more" (salesy) + +## Vulnerability Done Right + +Authentic sharing builds connection. Performed vulnerability destroys trust. + +### Strategic Vulnerability vs. Exploitation + +**Share when**: +- The experience taught you something others can learn +- You've processed it enough to offer perspective +- It serves the reader, not your need for validation +- Time has passed and you have hindsight + +**Don't share when**: +- The wound is still fresh +- You're seeking sympathy, not providing value +- It could harm others involved +- It feels performative even to you + +### The Vulnerability Test + +Before posting something personal, ask: +1. Am I sharing this to help others or to process my own feelings? +2. Would I be comfortable if this went viral? +3. Does this include genuine insight or just pain? +4. Have I protected others who were involved? + +## Post Formats + +### Story Post +Best for: Personal experiences, lessons learned, career moments + +``` +[Hook - honest admission or surprising outcome] + +[One sentence of context] + +[What happened - the tension or challenge] + +[The turning point] + +[What you learned] + +[Question or reflection for reader] +``` + +### List Post +Best for: Frameworks, actionable advice, curated insights + +``` +[Hook - clear value promise] + +[Why this matters - one sentence] + +1. [Point with brief context] +2. [Point with brief context] +3. [Point with brief context] +(3-7 items max) + +[Closing insight or question] +``` + +### Contrarian Post +Best for: Challenging conventional wisdom (with substance) + +``` +[Your contrarian position, stated directly] + +[The common belief you're challenging] + +[Your reasoning - why you see it differently] + +[Evidence or experience] + +[Nuanced conclusion - acknowledge complexity] + +[Invite discussion] +``` + +**Contrarian guardrails**: +- Have genuine expertise on the topic +- Argue against ideas, not people +- Offer an alternative, not just criticism +- Acknowledge what the other side gets right +- Be open to being wrong + +### Observation Post +Best for: Industry insights, trends, patterns you've noticed + +``` +[What you've observed] + +[Specific evidence or examples] + +[Why it matters] + +[Your interpretation] + +[Question to test if others see it too] +``` + +## Media Formats + +Text-only posts aren't the only option. Different formats serve different purposes. + +### Document/Carousel Posts +- Higher save rates and extended engagement +- Best for: step-by-step guides, frameworks, before/after +- Keep to 5-10 slides +- Each slide should stand alone +- End with a summary or CTA slide + +### Image Posts +- Use when visual adds genuine value (charts, diagrams, photos from events) +- Avoid: stock photos, generic graphics, memes (unless that's your brand) +- Native uploads outperform links + +### Polls +- Good for: genuine market research, sparking discussion +- Bad for: obvious questions, engagement farming +- Always follow up with your take on the results + +### Video +- LinkedIn favors native video over links +- Keep under 90 seconds for most content +- Captions are essential (most watch muted) +- Face-to-camera builds personal connection + +## Engagement Strategy + +What happens after you post matters as much as what you post. + +### The First Hour + +LinkedIn's algorithm weighs early engagement heavily: +- Respond to every comment in the first 60 minutes +- Ask follow-up questions to extend conversations +- Thank people genuinely, not generically + +### Comment Quality + +Your comments on others' posts build your brand too: +- Add insight, not just agreement +- Share relevant experience +- Ask thoughtful follow-up questions +- Avoid "Great post!" without substance + +### What Not to Do + +- Engagement pods (LinkedIn detects and penalizes these) +- Asking people to "comment for a free resource" +- Posting and disappearing +- Commenting just to get visibility + +## Writing Guidelines + +### Tone Spectrum + +Find your spot on each scale: + +``` +Corporate ←————————→ Casual +Reserved ←————————→ Vulnerable +Analytical ←————————→ Emotional +Serious ←————————→ Playful +``` + +Most Top Voices sit slightly right of center on each. + +### Length +- Optimal: 150-300 words (enough for depth, short enough to finish) +- Maximum: 3,000 characters +- Use whitespace generously—dense text kills engagement + +### Formatting Rules +- Never use hashtags inline with text +- Place 3-5 hashtags at the very end, after a line break +- One emoji max, if any (overuse signals inauthenticity) +- Use "I" not "we" for personal posts + +## What to Avoid + +**Content anti-patterns**: +- "I'm excited to announce..." (corporate speak) +- Humblebrags disguised as lessons +- Recycled viral post formats (the airport conversation, the Uber driver wisdom) +- Posting others' frameworks without credit +- Tragedy exploitation (using global events for engagement) + +**Engagement anti-patterns**: +- "Comment YES if you agree" +- "Share this with 3 people" +- Tagging people who didn't ask to be tagged +- Posting controversial takes you don't actually believe +- Fake questions where you just want to share your answer + +**Format anti-patterns**: +- Every. Sentence. As. Its. Own. Line. +- Excessive emoji strings +- ALL CAPS FOR EMPHASIS +- Hashtag stuffing + +## Content Categories That Perform + +1. **Career lessons** - Failures, pivots, decisions made +2. **Behind-the-scenes** - Real work experiences, how things actually work +3. **Industry insights** - Patterns you've noticed, trends you're seeing +4. **How-to frameworks** - Actionable processes readers can apply +5. **Contrarian takes** - Challenge assumptions (with substance) +6. **Personal milestones** - Only when tied to broader insights + +## References + +- `references/hooks.md` - Complete hook patterns with examples +- `references/examples.md` - Full post examples demonstrating best practices + +## Quick Checklist Before Posting + +1. **Hook**: Would this stop MY scroll? +2. **Focus**: Is there ONE clear idea? +3. **Value**: Would I find this useful if someone else posted it? +4. **Authenticity**: Does this sound like me, not "LinkedIn me"? +5. **Format**: Is it scannable with short paragraphs? +6. **Close**: Does it invite genuine engagement? +7. **Vulnerability check**: Am I sharing to help or to process? +8. **Cringe test**: Will I be proud of this post in a year? diff --git a/.cursor/skills/writing-linkedin-posts/references/examples.md b/.cursor/skills/writing-linkedin-posts/references/examples.md new file mode 100644 index 0000000..a54aaa2 --- /dev/null +++ b/.cursor/skills/writing-linkedin-posts/references/examples.md @@ -0,0 +1,360 @@ +# Full Post Examples + +Complete LinkedIn posts demonstrating best practices. Study the structure, tone, and engagement techniques. + +## Table of Contents +- [Story Posts](#story-posts) +- [List Posts](#list-posts) +- [Contrarian Posts](#contrarian-posts) +- [Observation Posts](#observation-posts) +- [Lesson Posts](#lesson-posts) + +--- + +## Story Posts + +### Example 1: Career Failure Story + +``` +I got fired on a Tuesday. + +No warning. No PIP. Just a 10-minute meeting and a box for my things. + +I'd given that company 4 years. Missed my kid's recitals. Skipped vacations. Answered emails at midnight. + +For a week, I couldn't get out of bed. + +Then something shifted. + +I realized I'd been building someone else's dream while neglecting my own. I'd traded my health, my relationships, and my creativity for a title that disappeared in 10 minutes. + +That firing became the best thing that happened to me. + +Within 6 months, I started my own consultancy. Within a year, I was making more than my old salary—working half the hours. + +Here's what I learned: + +Job security is an illusion. The only real security is your skills, your network, and your ability to create value. + +If you're pouring everything into a job at the expense of everything else—please reconsider. + +Your company won't remember your sacrifice. But your family will remember your absence. + +What's one thing you've sacrificed for work that you wish you hadn't? + +#careers #leadership #worklifebalance +``` + +**Why it works**: +- Confession hook creates immediate intrigue +- Short paragraphs maintain momentum +- Emotional journey with clear transformation +- Universal lesson that resonates broadly +- Ends with engaging question + +--- + +### Example 2: Unexpected Success Story + +``` +A stranger's comment changed my entire career. + +I'd just bombed a presentation. Forgot my lines. Lost my place in the slides. Watched the room mentally check out. + +Afterward, I was packing up when someone approached me. + +"That was terrible," she said. + +I braced myself. + +"But you recovered well. Most people would have panicked. You stayed calm and kept going. That's rare." + +She handed me her card. She was a VP at a company I'd dreamed of working for. + +Six weeks later, I had a job offer. + +The presentation skills I thought I needed? Secondary. + +The composure under pressure? That's what mattered. + +Your biggest weakness might be hiding your greatest strength. + +What skill do you have that you've been undervaluing? + +#professionaldevelopment #careers #softskills +``` + +**Why it works**: +- Opens with intriguing promise +- Sets up expectation (failure) then subverts it +- Dialogue makes it vivid and memorable +- Clear, applicable insight at the end + +--- + +## List Posts + +### Example 1: Actionable Framework + +``` +Stop saying "I don't have time." + +Say this instead: + +1. "It's not a priority right now." +(Honest. Acknowledges the choice you're making.) + +2. "I'm choosing to focus on [X] instead." +(Takes ownership. Shows intentionality.) + +3. "I'd need to move some things around. Let me check." +(Shows willingness without over-promising.) + +4. "I can do this if we push back [Y]." +(Collaborative. Sets realistic expectations.) + +5. "That's outside my bandwidth this quarter." +(Professional boundary-setting.) + +The words you use shape how you think. + +"I don't have time" is a victim statement. +"I'm choosing not to prioritize this" is an ownership statement. + +Same outcome. Completely different mindset. + +Which phrase resonates most with you? + +#productivity #communication #leadership +``` + +**Why it works**: +- Bold opening statement +- Numbered list is highly scannable +- Each item includes brief explanation +- Ends with insight that elevates the list +- Simple question to drive engagement + +--- + +### Example 2: Lessons Learned List + +``` +10 years in tech. Here's what I wish I knew on day 1: + +1. Your manager matters more than your company. + +2. The best opportunities aren't posted online. + +3. Being easy to work with beats being the smartest. + +4. Saying no is a skill. Practice it. + +5. Your network is built before you need it. + +6. Titles are temporary. Skills are permanent. + +7. The people who get promoted ask for promotions. + +8. Burnout sneaks up slowly, then hits all at once. + +That last one took me too long to learn. + +Save this if any of these hit home. + +What would you add to the list? + +#tech #careeradvice #leadership +``` + +**Why it works**: +- Clear context (10 years experience) +- Short, punchy items +- Personal note on final item adds authenticity +- Multiple CTAs (save + comment) + +--- + +## Contrarian Posts + +### Example 1: Challenging Common Advice + +``` +"Follow your passion" is terrible career advice. + +Here's why: + +Passion follows mastery, not the other way around. + +Think about it: +- You weren't passionate about walking until you could walk +- You weren't passionate about reading until you could read +- You weren't passionate about your job until you got good at it + +Cal Newport calls this the "craftsman mindset." + +Instead of asking "What am I passionate about?" + +Ask: "What am I willing to get good at?" + +The passion comes after the competence. + +I didn't love marketing when I started. I found it confusing and overwhelming. + +But I stuck with it. Got better. Started seeing results. + +Now? I genuinely love it. + +The passion came from the mastery. + +What skill did you grow to love only after getting good at it? + +#careers #passion #professionaldevelopment +``` + +**Why it works**: +- Directly challenges popular advice +- Provides reasoning (not just contrarian for clicks) +- Uses relatable examples +- Cites credible source +- Personal experience adds authenticity +- Reframe gives actionable alternative + +--- + +### Example 2: Industry Challenge + +``` +Unpopular opinion: Most networking is a waste of time. + +Hear me out. + +Standing in a room exchanging business cards with strangers you'll never talk to again? + +That's not networking. That's collecting paper. + +Real networking is: + +→ Helping someone without expecting anything back +→ Staying in touch with 10 people who matter vs. 100 who don't +→ Being known for one thing so people think of you +→ Following up (which 90% of people never do) + +I stopped going to "networking events" 3 years ago. + +My network has never been stronger. + +Because I focused on depth, not breadth. + +Quality over quantity applies to relationships too. + +How do you approach building your network? + +#networking #careers #relationships +``` + +**Why it works**: +- "Unpopular opinion" signals contrarian take +- Acknowledges the common approach before critiquing +- Provides concrete alternative behaviors +- Personal proof point +- Non-judgmental ending question + +--- + +## Observation Posts + +### Example 1: Trend Observation + +``` +I've interviewed 50+ candidates this year. + +One pattern keeps appearing: + +The candidates who get offers aren't the ones with the best resumes. + +They're the ones who ask the best questions. + +Anyone can rehearse answers. + +Few people prepare thoughtful questions that show: +- They researched the company +- They understand the role's challenges +- They're thinking about how they'll contribute + +The questions you ask reveal how you think. + +And how you think determines whether we want to work with you. + +If you're interviewing soon, spend as much time on your questions as your answers. + +What's the best question you've ever been asked in an interview? + +#hiring #interviews #careers +``` + +**Why it works**: +- Specific credibility (50+ interviews) +- Clear observation with business implication +- Actionable insight readers can use +- Invites sharing of experiences + +--- + +## Lesson Posts + +### Example 1: Single Insight Deep Dive + +``` +The most successful people I know share one habit: + +They protect their mornings. + +No meetings before 10am. +No emails until focused work is done. +No notifications during deep work blocks. + +They figured out something most people miss: + +Willpower is a finite resource. + +It's highest in the morning. It depletes throughout the day. + +So they use their best hours for their most important work. + +Not for reactive tasks. +Not for other people's priorities. +Not for putting out fires. + +I started protecting my mornings 2 years ago. + +It's the single change that's had the biggest impact on my output. + +Try it for one week. Just one. + +Block your calendar before 10am. See what happens. + +What's your most productive time of day? + +#productivity #leadership #habits +``` + +**Why it works**: +- Universal appeal (success habits) +- Simple, clear insight +- Explains the "why" behind the advice +- Personal testimony +- Low-barrier CTA (just one week) +- Easy question to answer + +--- + +## Formatting Reminders + +Notice in all examples: +- **Short paragraphs**: 1-3 sentences max +- **White space**: Line breaks between thoughts +- **One idea**: Each post has a single core message +- **Scannable**: Can grasp the point even when skimming +- **Hashtags**: 3-5 at the very end, not inline +- **Questions**: End with easy-to-answer engagement prompt diff --git a/.cursor/skills/writing-linkedin-posts/references/hooks.md b/.cursor/skills/writing-linkedin-posts/references/hooks.md new file mode 100644 index 0000000..70d7a28 --- /dev/null +++ b/.cursor/skills/writing-linkedin-posts/references/hooks.md @@ -0,0 +1,212 @@ +# Hook Patterns + +The hook is the most critical part of any LinkedIn post. These first 2-3 lines appear before the "See more" button and determine whether readers engage or scroll past. + +## The Evolution of LinkedIn Hooks + +LinkedIn audiences have grown sophisticated. What worked in 2020 often feels manipulative now. The best hooks in 2024-2025 feel genuine—they create curiosity without feeling engineered. + +**The shift**: From manufactured curiosity gaps → Honest, specific openings + +--- + +## Table of Contents +- [Honest Admission Hooks](#honest-admission-hooks) +- [Specific Observation Hooks](#specific-observation-hooks) +- [Direct Challenge Hooks](#direct-challenge-hooks) +- [Unexpected Angle Hooks](#unexpected-angle-hooks) +- [Simple Truth Hooks](#simple-truth-hooks) +- [Question Hooks](#question-hooks) +- [Transformation Hooks](#transformation-hooks) +- [Hooks to Retire](#hooks-to-retire) + +--- + +## Honest Admission Hooks + +Admit something real. Not performed vulnerability—genuine acknowledgment of a change in thinking, a mistake, or a limitation. + +**Pattern**: "[I was wrong / I've changed my mind / I used to believe] + [specific thing]" + +**Examples**: +- "I've been wrong about remote work." +- "I used to give terrible feedback. Here's what changed." +- "I spent three years optimizing for the wrong metric." +- "I dismissed this advice for a decade. I regret it." +- "My first instinct on this was completely backwards." +- "I finally understand why my team was frustrated." + +**Why it works**: Shows intellectual honesty and growth. Readers trust people who can admit mistakes. + +**What makes it authentic**: The admission is specific, not vague. You're sharing what you learned, not just confessing for sympathy. + +--- + +## Specific Observation Hooks + +Share something you've noticed with enough specificity that it signals real experience. + +**Pattern**: "[I've noticed/seen/observed] + [specific pattern] + [in specific context]" + +**Examples**: +- "I've noticed something in every founder who scaled past $10M." +- "After 200 interviews, one pattern keeps appearing." +- "The best managers I've worked with all do this differently." +- "I've watched 15 product launches this year. The failures share one thing." +- "Something strange happens when companies hit 50 employees." +- "Every successful negotiation I've seen starts the same way." + +**Why it works**: Specificity creates credibility. Numbers and contexts signal real experience, not theoretical advice. + +**What makes it authentic**: You've actually observed this. You can back it up with examples if asked. + +--- + +## Direct Challenge Hooks + +Challenge a common belief or practice directly. No hedge, no apology. + +**Pattern**: "[Common practice/belief] + [is wrong/doesn't work/misses the point]" + +**Examples**: +- "Most career advice optimizes for the wrong thing." +- "Your company's values statement is probably useless." +- "We've been thinking about productivity backwards." +- "The 'ideal candidate' doesn't exist. Stop looking." +- "Most feedback is actually just judgment in disguise." +- "Your morning routine isn't why you're successful." + +**Why it works**: Creates productive tension. Readers want to understand why you disagree with conventional wisdom. + +**What makes it authentic**: You have a real alternative to offer. You're not just contrarian for engagement—you've thought this through. + +--- + +## Unexpected Angle Hooks + +Subvert expectations by taking a familiar topic in an unfamiliar direction. + +**Pattern**: "[Surprising outcome] + [from unexpected source/approach]" + +**Examples**: +- "The best hire I ever made had the worst resume." +- "My biggest career win came from saying no." +- "The meeting that changed everything lasted 4 minutes." +- "I learned more from my worst boss than my best one." +- "The strategy that worked was the one I almost didn't try." +- "My most successful project started as a joke." + +**Why it works**: Subverts the expected narrative. Readers lean in to understand the disconnect. + +**What makes it authentic**: This actually happened. The story delivers on the hook's promise. + +--- + +## Simple Truth Hooks + +State something true that people feel but rarely say out loud. + +**Pattern**: "[Uncomfortable/unspoken truth about shared experience]" + +**Examples**: +- "Nobody talks about how lonely leadership is." +- "Most people hate their jobs. We just don't say it at work." +- "Networking feels fake because most of it is." +- "The 'dream job' often isn't." +- "Everyone's winging it. The confident ones are just better actors." +- "Burnout doesn't announce itself. It arrives quietly." + +**Why it works**: Resonates emotionally. Readers feel seen when someone names what they've been thinking. + +**What makes it authentic**: You're sharing genuine insight, not manufactured profundity. This comes from real observation. + +--- + +## Question Hooks + +Open with a question that makes readers stop and reflect. + +**Pattern**: "[Provocative question that challenges or resonates]?" + +**Examples**: +- "What if your biggest weakness is actually protecting you?" +- "When did 'busy' become a badge of honor?" +- "Are you building a career or just collecting jobs?" +- "What would you do if you weren't afraid of looking stupid?" +- "How many opportunities have you missed by 'waiting for the right time'?" +- "What's the decision you keep postponing?" + +**Why it works**: Questions activate the brain differently. Readers naturally pause to consider their answer. + +**What makes it authentic**: The question is genuine—you're actually curious about people's answers, not just using it as a device. + +--- + +## Transformation Hooks + +Show genuine before/after contrast with specific detail. + +**Pattern**: "[Specific past state] → [Specific current state]" + +**Examples**: +- "From 'I'll never be a manager' to leading a team of 40." +- "Burned out at 28. Running a company at 32." +- "Rejected by every firm I applied to. Now I run one." +- "Couldn't write a sentence without second-guessing. Now I write daily." +- "Terrified of public speaking. Just gave my first keynote." +- "From dreading Mondays to actually looking forward to work." + +**Why it works**: Transformation stories are inherently compelling. Readers want to know what changed. + +**What makes it authentic**: The transformation is real and recent enough to remember the journey. You can describe specific turning points. + +--- + +## Hooks to Retire + +These patterns have been overused to the point of triggering skepticism: + +| Tired Hook | Why It's Tired | Better Alternative | +|------------|----------------|-------------------| +| "This one thing made me $300K" | Feels like a scam ad | "Here's what actually moved the needle for my business" | +| "The CEO pulled me aside and said 3 words" | Manufactured suspense | "My CEO said something I didn't understand until years later" | +| "I'm excited to announce..." | Corporate speak | Just announce the thing directly | +| "X words that changed my life" | Too formulaic | Tell the actual story | +| "A stranger approached me at [airport/coffee shop]" | Overused format | Share the insight without the manufactured setting | +| "You won't believe what happened next" | Clickbait energy | Let the story speak for itself | +| "Here's why you're doing [X] wrong" | Preachy and presumptuous | "I used to do [X] this way. Here's what I changed." | +| "Stop doing [X]. Start doing [Y]." | Commands don't build connection | "What worked better for me was..." | + +--- + +## Hook Testing Framework + +When crafting hooks, test against these criteria: + +1. **Scroll-stop**: Would this make YOU pause mid-scroll? +2. **Authenticity**: Can you actually deliver on this hook? +3. **Specificity**: Is it concrete rather than vague? +4. **Earned**: Do you have the experience to back this up? +5. **Non-manipulative**: Does it feel honest or engineered? + +### Weak vs. Strong Examples + +| Weak | Strong | Why | +|------|--------|-----| +| "Here are some tips for success" | "Three things I wish I'd known at 25" | Specific, personal, implies story | +| "You need to hear this" | "This took me years to figure out" | Positions as sharing vs. lecturing | +| "The secret to productivity" | "I tried every productivity system. Only one stuck." | Experience-based, not prescriptive | +| "Everyone should know this" | "I learned this the hard way" | Humble, story-driven | + +--- + +## The Authenticity Test + +Before using any hook, ask: + +1. Did this actually happen / do I actually believe this? +2. Would I say this in a real conversation? +3. Does the post deliver on what the hook promises? +4. Will I be proud of this hook in a year? + +The best hooks don't feel like hooks. They feel like the natural start of something you genuinely want to share. diff --git a/.cursor/skills/writing-x-posts/SKILL.md b/.cursor/skills/writing-x-posts/SKILL.md new file mode 100644 index 0000000..6b5c9fa --- /dev/null +++ b/.cursor/skills/writing-x-posts/SKILL.md @@ -0,0 +1,159 @@ +--- +name: writing-x-posts +description: Create engaging X (Twitter) posts and threads that drive engagement and grow audience. Use this skill when asked to write X/Twitter content, create threads, craft viral tweets, or help with X engagement strategy. Triggers include requests for tweets, X posts, Twitter threads, or social media content for X. +--- + +# Writing X Posts + +Create engaging X (Twitter) posts and threads that capture attention, drive engagement, and grow your audience. + +## Core Principles + +### Brevity is Power +X rewards concise, punchy content. Every word must earn its place. If you can say it in fewer words, do it. + +### One Idea Per Tweet +Single tweets should contain one complete thought. Threads expand on ideas but each tweet should still stand alone. + +### Hook or Die +You have ~1 second to stop the scroll. The first line determines everything—engagement, reach, and whether anyone reads the rest. + +## Content Formats + +### Single Post +- **Length**: Under 280 characters (under 200 is better) +- **Purpose**: One sharp insight, observation, or take +- **Best for**: Hot takes, quick tips, observations, quotes + +### Thread +- **Length**: 5-10 tweets (7 is the sweet spot) +- **Purpose**: Deep dives, stories, frameworks, lists +- **Structure**: Each tweet stands alone but builds narrative +- **Best for**: Tutorials, stories, breakdowns, listicles + +## Post Anatomy + +### The Hook (Tweet 1) +The hook has one job: make someone stop scrolling. + +**Hook formula**: +1. **Bold statement** - Surprise, shock, or make a claim +2. **Tension** - Highlight a struggle or pain point +3. **Twist** - Flip expectations +4. **Open loop** - Tease what's coming + +See `references/hooks.md` for detailed hook patterns with examples. + +### The Body (Tweets 2-6) +- Keep each tweet under 250 characters +- Use cliffhangers every 1-2 tweets +- Add visual breaks every 3-4 tweets +- White space and short sentences aid scanning + +### The Close (Final tweets) +- **Punchy lesson**: Crystallize the main takeaway +- **Soft CTA**: Invite engagement naturally +- **Final CTA**: Direct ask (follow, repost, reply) + +## Thread Frameworks + +### Storytelling Framework +Best for: Personal experiences, case studies + +``` +1/ [Hook - outcome or surprising moment] +2/ [Set the scene - context] +3/ [The challenge/conflict] +4/ [The journey - what happened] +5/ [The turning point] +6/ [The lesson] +7/ [CTA - question or follow prompt] +``` + +### Listicle Framework +Best for: Tips, tools, resources + +``` +1/ [Hook - promise of value] +2/ [Item 1 - with brief explanation] +3/ [Item 2] +4/ [Item 3] +5/ [Item 4] +6/ [Item 5] +7/ [Summary + CTA] +``` + +### Problem-Solution Framework +Best for: Educational content, how-tos + +``` +1/ [Hook - the problem everyone faces] +2/ [Agitate - why it's painful] +3/ [Solution intro] +4/ [Step 1] +5/ [Step 2] +6/ [Step 3] +7/ [Results + CTA] +``` + +### Contrarian Framework +Best for: Thought leadership, hot takes + +``` +1/ [Bold contrarian statement] +2/ [What everyone believes] +3/ [Why they're wrong] +4/ [Your counter-argument] +5/ [Evidence/experience] +6/ [The nuanced truth] +7/ [Engagement question] +``` + +## Writing Guidelines + +### Tone +- Conversational, not formal +- Confident, not arrogant +- Slightly provocative (when appropriate) +- Authentic, not performative + +### Formatting +- Short sentences +- Generous white space +- Line breaks for emphasis +- 1-2 hashtags max (or none) +- Emojis sparingly (if brand allows) + +### What to Avoid +- Corporate speak +- Excessive hashtags +- Links in main tweet (reduces reach) +- Asking for engagement without providing value +- Walls of text + +## Engagement Mechanics + +### Algorithm Signals +- Time-on-post (longer = better) +- Quick engagement in first hour +- Replies and quote tweets +- Saves and shares + +### Best Posting Times +- Tuesday-Thursday, 9-11am and 1-3pm EST +- Consistency matters more than perfect timing +- 2-3 quality threads per week beats daily mediocrity + +## References + +- `references/hooks.md` - Detailed hook patterns with examples +- `references/examples.md` - Full thread and single post examples + +## Quick Checklist + +1. Does the hook stop the scroll? +2. Is each tweet under 250 characters? +3. Can each tweet stand alone? +4. Is there a clear payoff for reading? +5. Does it end with engagement opportunity? +6. Zero corporate speak? diff --git a/.cursor/skills/writing-x-posts/references/examples.md b/.cursor/skills/writing-x-posts/references/examples.md new file mode 100644 index 0000000..f840aac --- /dev/null +++ b/.cursor/skills/writing-x-posts/references/examples.md @@ -0,0 +1,334 @@ +# X Post Examples + +Full examples demonstrating best practices for single posts and threads. + +## Table of Contents +- [Single Posts](#single-posts) +- [Thread Examples](#thread-examples) + +--- + +## Single Posts + +### Hot Take Post + +``` +Unpopular opinion: + +The best time to start was 10 years ago. + +The second best time is NOT today. + +It's after you've actually thought through what you're starting and why. + +"Just start" is lazy advice. +``` + +**Why it works**: Contrarian hook, challenges popular wisdom, makes reader think. + +--- + +### Observation Post + +``` +I've noticed something about successful people: + +They don't manage time. They manage energy. + +They know when they're sharp and protect those hours ruthlessly. + +Everything else gets the scraps. +``` + +**Why it works**: Simple observation, feels insightful, actionable implication. + +--- + +### Quick Win Post + +``` +A negotiation trick that's worked every time: + +When they make an offer, stay silent for 10 seconds. + +Most people can't handle the pause. + +They'll often improve the offer just to fill the silence. +``` + +**Why it works**: Specific technique, easy to try, immediate value. + +--- + +### Reframe Post + +``` +Reframe: + +You're not "bad at networking." + +You're bad at pretending to care about people you don't care about. + +Solution: Stop going to networking events. Start helping people you actually like. +``` + +**Why it works**: Validates reader's struggle, offers new perspective, actionable advice. + +--- + +## Thread Examples + +### Storytelling Thread + +``` +1/ I got fired on my birthday. + +No warning. No severance. Just a box and a security escort. + +Here's what happened next (and how it became the best thing for my career): + +2/ Some context: + +I'd been at the company for 3 years. +Good reviews. Promotions. Everything seemed fine. + +Then new leadership came in and cleaned house. + +I was out. + +3/ The first week was brutal. + +I applied to 50 jobs. Got 2 responses. +Both rejections. + +I started questioning everything about my career. + +4/ Then I did something different. + +Instead of applying to more jobs, I reached out to 10 people I admired and asked one question: + +"If you were starting over today, what would you do differently?" + +5/ The responses changed everything. + +8 out of 10 said some version of: + +"I would have bet on myself sooner." + +They'd all spent years building someone else's dream before building their own. + +6/ That's when it clicked. + +Getting fired wasn't a setback. +It was a forced decision I'd been too scared to make myself. + +I had nothing to lose now. + +7/ I started freelancing that month. + +First client: $500. +By month 6: $10K/month. +By year 2: More than my old salary. + +Getting fired gave me permission to try. + +8/ The lesson: + +Sometimes the worst thing that happens to you is actually the best thing. + +You just can't see it yet. + +If you're going through something hard right now—keep going. + +What's a setback that turned into your biggest breakthrough? +``` + +**Why it works**: +- Hook creates immediate curiosity +- Story has clear arc (setup → conflict → resolution) +- Each tweet stands alone but builds narrative +- Universal lesson at the end +- Ends with engagement question + +--- + +### Listicle Thread + +``` +1/ 7 harsh truths about career growth that nobody wants to hear: + +(Save this—you'll need it) + +2/ 1. Your work doesn't speak for itself. + +Never has. Never will. + +The people who get promoted are the ones who make their work visible. + +Stop waiting to be discovered. + +3/ 2. Your manager doesn't owe you mentorship. + +They have their own job to do. + +If you want mentorship, make it easy for them. Come with specific questions, not vague requests. + +4/ 3. Being busy isn't the same as being valuable. + +Nobody cares how many hours you worked. + +They care about results. + +Work less. Deliver more. + +5/ 4. Your degree matters less every year you're out of school. + +By year 5, nobody cares where you went to college. + +They care what you've done since. + +6/ 5. The best opportunities aren't posted. + +They're created through relationships. + +If you're only applying to job postings, you're competing with 500 other people. + +7/ 6. Nobody is coming to save your career. + +Not HR. Not your manager. Not the company. + +You are the CEO of your own career. + +Act like it. + +8/ 7. Your "dream job" is probably a fantasy. + +Every job has boring parts. + +Stop looking for perfect. Start looking for growth. + +Which one hit hardest? Reply with the number. +``` + +**Why it works**: +- Clear promise in hook +- Each tweet is standalone insight +- Consistent structure throughout +- "Save this" CTA early +- Engagement prompt at end + +--- + +### Problem-Solution Thread + +``` +1/ Most people are terrible at cold emails. + +Here's the exact template that got me a 40% response rate: + +(Including the psychology behind why it works) + +2/ First, why most cold emails fail: + +- Too long +- Too much about you +- No clear ask +- Generic flattery + +The reader thinks: "What do they want and why should I care?" + +3/ The template (steal this): + +Subject: [Specific thing you noticed about them] + +[One sentence about what impressed you - be specific] + +[One sentence about why you're reaching out] + +[One clear, small ask] + +[Your name] + +That's it. 4-5 sentences max. + +4/ Let's break down each part: + +SUBJECT LINE: +Make it specific to them. + +Bad: "Quick question" +Good: "Your thread on pricing strategy" + +Specificity = they know you actually did your homework. + +5/ THE OPENER: + +One genuine, specific compliment. + +Bad: "I love your content" +Good: "Your breakdown of the Amazon flywheel is the clearest explanation I've seen" + +Specific > Generic. Always. + +6/ THE REASON: + +Why are you reaching out? Make it about THEM, not you. + +Bad: "I'd love to pick your brain" +Good: "I'm working on a similar problem and think you might have insight" + +7/ THE ASK: + +Make it small and easy to say yes to. + +Bad: "Can we hop on a 30-min call?" +Good: "Would you be open to a 2-question email exchange?" + +Lower barrier = higher response rate. + +8/ Real example that worked: + +Subject: Your pricing experiment + +Hey [Name], + +Your thread on testing price elasticity was exactly what I needed. The spreadsheet framework was genius. + +I'm running similar tests and hit a wall with sample size. + +Would you be open to a quick email exchange? Just 2 questions. + +Thanks, +[Me] + +Response rate: 40%+ + +9/ Why this works: + +1. Shows you paid attention (specific reference) +2. Shows you're doing the work (not just asking for free advice) +3. Makes saying yes easy (2 questions via email) + +Respect their time. Be specific. Make it easy. + +Save this for your next cold outreach. +``` + +**Why it works**: +- Specific promise with social proof (40% rate) +- Teaches the "why" not just the "what" +- Includes real example +- Each tweet adds new value +- Actionable and immediately usable + +--- + +## Formatting Reminders + +In all examples, notice: +- **Short sentences**: Easier to scan +- **White space**: Line breaks between thoughts +- **Numbered threads**: 1/, 2/, 3/ format +- **Cliffhangers**: Keep readers moving forward +- **Engagement ask**: End with question or save prompt +- **No hashtags cluttering**: 0-2 max, at end if used diff --git a/.cursor/skills/writing-x-posts/references/hooks.md b/.cursor/skills/writing-x-posts/references/hooks.md new file mode 100644 index 0000000..da63acc --- /dev/null +++ b/.cursor/skills/writing-x-posts/references/hooks.md @@ -0,0 +1,186 @@ +# X Hook Patterns + +The hook is everything on X. You have ~1 second to stop the scroll. These patterns are proven to capture attention. + +## Table of Contents +- [Bold Claim Hooks](#bold-claim-hooks) +- [Transformation Hooks](#transformation-hooks) +- [Contrarian Hooks](#contrarian-hooks) +- [Curiosity Gap Hooks](#curiosity-gap-hooks) +- [List Promise Hooks](#list-promise-hooks) +- [Question Hooks](#question-hooks) +- [Story Hooks](#story-hooks) +- [Data Hooks](#data-hooks) + +--- + +## Bold Claim Hooks + +Make a statement so bold readers must understand how it's true. + +**Pattern**: "[Surprising claim that seems too good/bad to be true]" + +**Examples**: +- "I went from 0 to 10,000 followers in 57 days without posting a single thread." +- "One cold email changed my entire career." +- "I made more in 30 days than I did in a year at my job." +- "The best marketing strategy costs $0." +- "I built a $100K business with a skill I learned in a weekend." + +**Why it works**: The gap between the claim and believability forces readers to seek explanation. + +--- + +## Transformation Hooks + +Show dramatic before/after contrast. + +**Pattern**: "[Past state] → [Current state] + [Timeframe or method tease]" + +**Examples**: +- "6 months ago I was broke. Today I turned down a $200K offer. Here's what changed:" +- "From rejected by 50 companies to 3 competing offers in 8 weeks." +- "I went from anxious mess to calm leader. This framework saved me:" +- "0 followers → 50K in 6 months. No ads. No luck. Just this:" +- "Burned out employee → thriving founder. The pivot that changed everything:" + +**Why it works**: Transformation stories are inherently compelling. Readers want the roadmap. + +--- + +## Contrarian Hooks + +Challenge what everyone believes. + +**Pattern**: "[Popular belief] is wrong/dead/overrated. Here's why:" + +**Examples**: +- "Unpopular opinion: Networking events are useless." +- "Stop setting goals. Seriously. Here's what to do instead:" +- "The 'rise and grind' mentality is destroying your career." +- "Your resume doesn't matter anymore. Here's what does:" +- "Most productivity advice is garbage. Here's what actually works:" + +**Why it works**: Challenges create cognitive dissonance. Readers need resolution. + +--- + +## Curiosity Gap Hooks + +Tease information without revealing it. + +**Pattern**: "[Intriguing setup] + [Incomplete information]" + +**Examples**: +- "The CEO said 3 words that changed how I think about leadership forever." +- "I asked 100 millionaires one question. The answers shocked me." +- "There's one thing every successful person does that nobody talks about." +- "I made one change to my morning routine. Everything improved." +- "My mentor's advice sounded crazy at first. Then I tried it." + +**Why it works**: The brain craves closure. Incomplete information compels clicks. + +--- + +## List Promise Hooks + +Promise specific, valuable information. + +**Pattern**: "[Number] [things/tips/lessons/mistakes] + [outcome/topic]" + +**Examples**: +- "7 lessons from building a $1M business (that I wish I knew at 25):" +- "5 habits that seem lazy but actually make you more productive:" +- "10 things I'd tell my younger self about career growth:" +- "8 books that changed how I think about money:" +- "6 mistakes I made hiring my first 10 employees:" + +**Why it works**: Numbers create specificity. Lists promise organized, digestible value. + +--- + +## Question Hooks + +Open with a provocative question. + +**Pattern**: "[Question that challenges or resonates deeply]?" + +**Examples**: +- "Why do smart people make terrible decisions?" +- "What's the one skill nobody talks about but everyone needs?" +- "Are you building a career or just collecting paychecks?" +- "What if everything you know about success is wrong?" +- "Why are the busiest people often the least productive?" + +**Why it works**: Questions activate different brain patterns. Readers naturally seek to answer. + +--- + +## Story Hooks + +Start in the middle of action. + +**Pattern**: "[Dramatic moment or dialogue that drops reader into a scene]" + +**Examples**: +- "The investor looked at me and said, 'This will never work.'" +- "I was 30 seconds from quitting when everything changed." +- "My phone rang at 3am. It was the call that changed everything." +- "I stared at my bank account: $47. I had a decision to make." +- "'You're fired.' Two words. One moment. Everything shifted." + +**Why it works**: Stories create immediate emotional engagement. Readers need to know what happens. + +--- + +## Data Hooks + +Lead with surprising statistics or results. + +**Pattern**: "[Specific number/stat] + [unexpected context]" + +**Examples**: +- "I analyzed 1,000 viral threads. 90% follow this exact pattern:" +- "After 500 cold emails, I learned one thing that 10x'd my response rate." +- "I tracked my time for 30 days. The results were embarrassing." +- "87% of people make this mistake in negotiations. Here's the fix:" +- "I interviewed 50 founders. They all said the same thing about failure:" + +**Why it works**: Specific numbers create credibility. Data implies proven insights. + +--- + +## Thread-Specific Techniques + +### The Open Loop +End tweet 1 with incomplete information that's resolved in tweet 2: +- "Here's what happened next:" +- "But here's the thing nobody tells you:" +- "The real story is more interesting:" + +### The Cliffhanger +Use at end of tweets 2-5 to maintain momentum: +- "And then it got worse." +- "But that wasn't the real lesson." +- "Here's where it gets interesting:" + +### The Pattern Interrupt +Break expected flow to recapture attention: +- "Stop." +- "Read that again." +- "This is the part most people skip." + +--- + +## Hook Testing Framework + +Before posting, verify your hook: + +1. **Scroll-stop**: Would YOU stop scrolling for this? +2. **Specificity**: Is it concrete or vague? +3. **Promise**: Is the value proposition clear? +4. **Curiosity**: Does it create an information gap? +5. **Believability**: Bold enough to intrigue, believable enough to trust? + +**Weak**: "Here's some advice on growing on Twitter." +**Strong**: "I grew from 0 to 25K followers in 90 days. Here's the exact playbook (no fluff):" diff --git a/actions/content-plan.test.ts b/actions/content-plan.test.ts index 3f00b3a..4358394 100644 --- a/actions/content-plan.test.ts +++ b/actions/content-plan.test.ts @@ -45,24 +45,12 @@ const validCreatePlanInput = { prompt: 'Create posts about productivity tips', }; -function generateMockPosts(postsPerDay: number): { dayIndex: number; timeSlot: string; content: string }[] { - const slots = ['morning', 'afternoon', 'evening']; - const posts: { dayIndex: number; timeSlot: string; content: string }[] = []; - for (let day = 0; day < 7; day++) { - for (let slot = 0; slot < postsPerDay; slot++) { - posts.push({ - dayIndex: day, - timeSlot: slots[slot] ?? 'morning', - content: `Day ${day} ${slots[slot] ?? 'morning'} post`, - }); - } - } - - return posts; +function generateMockPosts(count: number): string[] { + return Array.from({ length: count }, (_, i) => `Post ${i + 1} content`); } const mockGeminiResponse = { - content: JSON.stringify(generateMockPosts(2)), + content: JSON.stringify(generateMockPosts(14)), // 2 posts/day * 7 days model: 'openai/gpt-4o', usage: { inputTokens: 1000, outputTokens: 500 }, }; @@ -209,12 +197,12 @@ describe('createPlan', () => { expect(mockCallAI).toHaveBeenCalledTimes(1); }); - it('should handle Gemini response with unescaped quotes in content', async () => { + it('should handle AI response with unescaped quotes in content', async () => { setupFullMocks(); const onePostInput = { ...validCreatePlanInput, postsPerDay: 1 }; - // Build 7 malformed posts (1 per day) with unescaped quotes + // Build 7 posts with unescaped quotes — malformed JSON that the regex fallback should handle const malformedEntries = Array.from({ length: 7 }, (_, i) => - ` {\n "dayIndex": ${i},\n "timeSlot": "morning",\n "content": "Don't build "the Uber for X" - day ${i}"\n }`, + ` "Don't build "the Uber for X" - day ${i}"`, ); const malformedResponse = { content: `[\n${malformedEntries.join(',\n')}\n]`, @@ -229,36 +217,11 @@ describe('createPlan', () => { expect(result.data?.planId).toBe('plan-123'); }); - it('should handle Gemini response with single-quoted content values', async () => { - setupFullMocks(); - const onePostInput = { ...validCreatePlanInput, postsPerDay: 1 }; - // Build 7 malformed posts with single-quoted content - const singleQuoteEntries = Array.from({ length: 7 }, (_, i) => - ` {\n "dayIndex": ${i},\n "timeSlot": "morning",\n "content": 'It ain\\'t easy - day ${i}'\n }`, - ); - const singleQuoteResponse = { - content: `[\n${singleQuoteEntries.join(',\n')}\n]`, - model: 'openai/gpt-4o', - usage: { inputTokens: 1000, outputTokens: 500 }, - }; - mockCallAI.mockResolvedValue(singleQuoteResponse); - - const result = await createPlan(onePostInput); - - expect(result.success).toBe(true); - expect(result.data?.planId).toBe('plan-123'); - }); - - it('should return error when Gemini returns fewer posts than expected', async () => { + it('should return error when AI returns fewer posts than expected', async () => { setupFullMocks(); - // postsPerDay=2 expects 14 posts, but Gemini only returns 4 + // postsPerDay=2 expects 14 posts, but AI only returns 4 const partialResponse = { - content: JSON.stringify([ - { dayIndex: 0, timeSlot: 'morning', content: 'Monday morning' }, - { dayIndex: 0, timeSlot: 'afternoon', content: 'Monday afternoon' }, - { dayIndex: 1, timeSlot: 'morning', content: 'Tuesday morning' }, - { dayIndex: 1, timeSlot: 'afternoon', content: 'Tuesday afternoon' }, - ]), + content: JSON.stringify(['Monday morning', 'Monday afternoon', 'Tuesday morning', 'Tuesday afternoon']), model: 'openai/gpt-4o', usage: { inputTokens: 1000, outputTokens: 500 }, }; @@ -287,7 +250,7 @@ describe('createPlan', () => { const callArgs = mockCallAI.mock.calls[0][0]; expect(callArgs.system).toContain('ghostwrite'); expect(callArgs.system).toContain('content_strategy'); - expect(callArgs.messages[0].content).toBe(validCreatePlanInput.prompt); + expect(callArgs.messages[0].content).toContain(validCreatePlanInput.prompt); }); it('should deduct credits after successful plan creation', async () => { diff --git a/actions/content-plan.ts b/actions/content-plan.ts index edabe54..21018be 100644 --- a/actions/content-plan.ts +++ b/actions/content-plan.ts @@ -25,10 +25,9 @@ import type { IContentPlan, IGenerationHistoryMessage, IMemoryItem, IPost } from // ─── Types ─────────────────────────────────────────────────────────────────── -interface IPlanPost { - dayIndex: number; - timeSlot: string; - content: string; +interface IPlanResponse { + title?: string; + posts: string[]; } interface ICreatePlanResult { @@ -75,117 +74,97 @@ interface IActionResult { // ─── Helper Functions ──────────────────────────────────────────────────────── /** - * Parse Gemini's JSON response containing an array of plan posts. + * Parse AI's JSON response containing a flat array of post strings or a titled plan object. * - * LLMs sometimes produce invalid JSON: - * - Single-quoted content strings: `"content": 'text here'` - * - Unescaped double quotes inside values: `"content": "build "the app""` + * Handles two formats: + * - Array: ["post 1 text", "post 2 text", ...] + * - Object with title: {"title": "...", "posts": ["post 1 text", ...]} * - * We try multiple strategies: JSON.parse, normalization, then regex extraction. + * LLMs sometimes produce invalid JSON with unescaped double quotes inside strings. + * We try multiple strategies: JSON.parse, then regex extraction. */ -function tryParsePostsJson(raw: string): IPlanPost[] | null { +function tryParseResponse(raw: string): IPlanResponse | null { // 1. Try standard JSON.parse try { - return JSON.parse(raw) as IPlanPost[]; + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed) && parsed.every((p) => typeof p === 'string')) { + return { posts: parsed as string[] }; + } + if (parsed && typeof parsed === 'object' && 'posts' in parsed) { + const obj = parsed as { title?: string; posts: unknown[] }; + if (Array.isArray(obj.posts) && obj.posts.every((p) => typeof p === 'string')) { + return { title: typeof obj.title === 'string' ? obj.title : undefined, posts: obj.posts as string[] }; + } + } } catch { // continue to fallbacks } - // 2. Try normalizing single-quoted content values to double-quoted - try { - const normalized = raw.replace( - /("content"\s*:\s*)'([\s\S]*?)'\s*(\n\s*\})/g, - (_match, prefix: string, content: string, suffix: string) => { - const escaped = content.replace(/"/g, '\\"'); - return `${prefix}"${escaped}"${suffix}`; - }, - ); - return JSON.parse(normalized) as IPlanPost[]; - } catch { - // continue to regex fallback + // 2. Try extracting title from object wrapper first + let extractedTitle: string | undefined; + const titleMatch = /"title"\s*:\s*"([^"]+)"/.exec(raw); + if (titleMatch) { + extractedTitle = titleMatch[1]; } - // 3. Regex fallback — extract posts from malformed JSON + // 3. Regex fallback — extract quoted strings from the posts array try { - const posts: IPlanPost[] = []; - - // Try double-quoted content first - const dqRegex = /"dayIndex"\s*:\s*(\d+)\s*,\s*"timeSlot"\s*:\s*"([^"]+)"\s*,\s*"content"\s*:\s*"([\s\S]*?)"\s*\n?\s*\}/g; - let match: RegExpExecArray | null = dqRegex.exec(raw); - while (match !== null) { - posts.push({ - dayIndex: parseInt(match[1] ?? '0', 10), - timeSlot: match[2] ?? 'morning', - content: (match[3] ?? '').replace(/\\n/g, '\n').replace(/\\"/g, '"'), - }); - match = dqRegex.exec(raw); - } - if (posts.length > 0) return posts; - - // Try single-quoted content (LLMs sometimes use ' as string delimiter) - const sqRegex = /"dayIndex"\s*:\s*(\d+)\s*,\s*"timeSlot"\s*:\s*"([^"]+)"\s*,\s*"content"\s*:\s*'([\s\S]*?)'\s*\n?\s*\}/g; - match = sqRegex.exec(raw); - while (match !== null) { - posts.push({ - dayIndex: parseInt(match[1] ?? '0', 10), - timeSlot: match[2] ?? 'morning', - content: (match[3] ?? '').replace(/\\n/g, '\n').replace(/\\'/g, "'"), - }); - match = sqRegex.exec(raw); + // Find the posts array (either top-level or inside "posts" key) + const postsArrayMatch = /"posts"\s*:\s*\[([^[\]]*(?:\[[^\]]*\][^[\]]*)*)\]/.exec(raw) + ?? /^\s*\[([^[\]]*(?:\[[^\]]*\][^[\]]*)*)\]\s*$/.exec(raw); + + if (postsArrayMatch) { + const arrayContent = postsArrayMatch[1] ?? ''; + const posts: string[] = []; + // Extract double-quoted strings, handling escaped quotes inside + const strRegex = /"((?:[^"\\]|\\.)*)"/g; + let match: RegExpExecArray | null = strRegex.exec(arrayContent); + while (match !== null) { + posts.push((match[1] ?? '').replace(/\\n/g, '\n').replace(/\\"/g, '"')); + match = strRegex.exec(arrayContent); + } + if (posts.length > 0) return { title: extractedTitle, posts }; } - - return posts.length > 0 ? posts : null; } catch { return null; } + + return null; } -function generateRandomTimes(postsPerDay: number): string[] { - const START_MINUTES = 7 * 60; - const END_MINUTES = 23 * 60; - const AVOID_START = 2 * 60; - const AVOID_END = 6 * 60; - const MIN_GAP_MINUTES = 45; - const totalMinutes = END_MINUTES - START_MINUTES; - const slotSize = Math.floor(totalMinutes / postsPerDay); +const SCHEDULE_START_HOUR = 8; +const SCHEDULE_END_HOUR = 20; + +/** + * Distributes post times evenly across a time window with a small random jitter. + * + * Algorithm: + * - Divide the window into equal slots of size `gap = totalMinutes / postsPerDay` + * - Place each post at the center of its slot: `slotStart + gap / 2` + * - Apply jitter of ±10% of `gap` to avoid mechanical uniformity + * - Use a random minute (0–59) within the computed hour + * - Returns a sorted list of "HH:mm" strings + */ +function distributeTimesInRange( + postsPerDay: number, + startHour: number = SCHEDULE_START_HOUR, + endHour: number = SCHEDULE_END_HOUR, +): string[] { + const startMinutes = startHour * 60; + const totalMinutes = (endHour - startHour) * 60; + const gap = totalMinutes / postsPerDay; + const jitterRange = gap * 0.1; const times: string[] = []; for (let i = 0; i < postsPerDay; i++) { - let candidate = -1; - let attempts = 0; - - while (attempts < 20) { - const slotStart = START_MINUTES + i * slotSize; - const jitter = Math.floor(Math.random() * slotSize); - const minuteOfDay = Math.min(slotStart + jitter, END_MINUTES - 1); - - const isInQuietHours = minuteOfDay >= AVOID_START && minuteOfDay < AVOID_END; - if (isInQuietHours) { - attempts++; - continue; - } + const slotCenter = startMinutes + i * gap + gap / 2; + const jitter = (Math.random() * 2 - 1) * jitterRange; + const baseMinuteOfDay = Math.round(Math.min(Math.max(startMinutes, slotCenter + jitter), endHour * 60 - 1)); - const tooClose = times.some((t) => { - const [h, m] = t.split(':').map(Number); - const existing = (h ?? 0) * 60 + (m ?? 0); - return Math.abs(existing - minuteOfDay) < MIN_GAP_MINUTES; - }); + const hour = Math.floor(baseMinuteOfDay / 60); + const minute = Math.floor(Math.random() * 60); - if (!tooClose) { - candidate = minuteOfDay; - break; - } - - attempts++; - } - - if (candidate === -1) { - candidate = START_MINUTES + i * slotSize; - } - - const hour = Math.floor(candidate / 60); - const minute = candidate % 60; times.push(`${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`); } @@ -317,7 +296,7 @@ export async function createPlan(data: CreatePlanFormData): Promise = {}; - for (let d = 0; d < numberOfDays; d++) { - timesByDay[d] = generateRandomTimes(validated.postsPerDay); - } + // Pre-generate times per day to ensure uniqueness within each day + const timesByDay: string[][] = Array.from({ length: numberOfDays }, () => + distributeTimesInRange(validated.postsPerDay), + ); + + const postsToInsert = planPosts.map((content, index) => { + const dayIndex = Math.floor(index / validated.postsPerDay); + const postIndexInDay = index % validated.postsPerDay; - const postsToInsert = planPosts.map((post) => { const dayDate = new Date(startDate); - dayDate.setDate(dayDate.getDate() + post.dayIndex); + dayDate.setDate(dayDate.getDate() + dayIndex); - const dayTimes = timesByDay[post.dayIndex] ?? generateRandomTimes(validated.postsPerDay); - const timeIndex = post.timeSlot === 'morning' ? 0 : post.timeSlot === 'afternoon' ? 1 : 2; - const time = dayTimes[Math.min(timeIndex, dayTimes.length - 1)] ?? '12:00'; + const dayTimes = timesByDay[dayIndex] ?? distributeTimesInRange(validated.postsPerDay); + const time = dayTimes[Math.min(postIndexInDay, dayTimes.length - 1)] ?? '09:00'; const [hours, minutes] = time.split(':'); const scheduledAt = new Date(dayDate); - scheduledAt.setHours(parseInt(hours ?? '12', 10), parseInt(minutes ?? '0', 10), 0, 0); + scheduledAt.setHours(parseInt(hours ?? '9', 10), parseInt(minutes ?? '0', 10), 0, 0); return { user_id: user.id, content_plan_id: plan.id, type: 'post' as const, - content: post.content, + content, original_input: validated.prompt, sphere_id: validated.sphereId ?? null, platform_id: validated.platformId ?? null, @@ -434,8 +419,7 @@ export async function createPlan(data: CreatePlanFormData): Promise ({ role: msg.role as 'user' | 'assistant', @@ -714,11 +701,13 @@ export async function regeneratePlan(data: RegeneratePlanFormData): Promise = {}; - for (let d = 0; d < numberOfDays; d++) { - timesByDay[d] = generateRandomTimes(plan.posts_per_day); - } + // Pre-generate times per day with even distribution + const timesByDay: string[][] = Array.from({ length: numberOfDays }, () => + distributeTimesInRange(plan.posts_per_day), + ); + + const postsToInsert = planPosts.map((content, index) => { + const dayIndex = Math.floor(index / plan.posts_per_day); + const postIndexInDay = index % plan.posts_per_day; - const postsToInsert = planPosts.map((post) => { const dayDate = new Date(planStartDate); - dayDate.setDate(dayDate.getDate() + post.dayIndex); + dayDate.setDate(dayDate.getDate() + dayIndex); - const dayTimes = timesByDay[post.dayIndex] ?? generateRandomTimes(plan.posts_per_day); - const timeIndex = post.timeSlot === 'morning' ? 0 : post.timeSlot === 'afternoon' ? 1 : 2; - const time = dayTimes[Math.min(timeIndex, dayTimes.length - 1)] ?? '12:00'; + const dayTimes = timesByDay[dayIndex] ?? distributeTimesInRange(plan.posts_per_day); + const time = dayTimes[Math.min(postIndexInDay, dayTimes.length - 1)] ?? '09:00'; const [hours, minutes] = time.split(':'); const scheduledAt = new Date(dayDate); - scheduledAt.setHours(parseInt(hours ?? '12', 10), parseInt(minutes ?? '0', 10), 0, 0); + scheduledAt.setHours(parseInt(hours ?? '9', 10), parseInt(minutes ?? '0', 10), 0, 0); return { user_id: user.id, content_plan_id: validated.planId, type: 'post' as const, - content: post.content, + content, sphere_id: plan.sphere_id, platform_id: plan.platform_id, style_id: plan.style_id, @@ -781,8 +771,7 @@ export async function regeneratePlan(data: RegeneratePlanFormData): Promise ({ + mockFrom: vi.fn(), + mockAdminFrom: vi.fn(), + mockAuth: vi.fn(), + mockCreateSignedUrl: vi.fn(), + mockRemove: vi.fn(), + mockRevalidatePath: vi.fn(), +})); + +vi.mock('@/lib/supabase/server', () => ({ + createServerClient: vi.fn().mockResolvedValue({ + from: mockFrom, + auth: { getUser: mockAuth }, + }), + createServiceRoleClient: vi.fn(() => ({ + from: mockAdminFrom, + storage: { + from: vi.fn(() => ({ + createSignedUrl: mockCreateSignedUrl, + remove: mockRemove, + })), + createBucket: vi.fn(), + }, + })), +})); + +vi.mock('next/cache', () => ({ + revalidatePath: mockRevalidatePath, +})); + +import { attachMediaToPost, deletePostMedia } from './media'; + +function createChainMock(result: { data: unknown; error: unknown }): Record> { + const chain: Record> & { then?: unknown } = {}; + + chain.select = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.delete = vi.fn().mockReturnValue(chain); + chain.eq = vi.fn().mockReturnValue(chain); + chain.in = vi.fn().mockReturnValue(chain); + chain.single = vi.fn().mockResolvedValue(result); + chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(result).then(resolve); + + return chain; +} + +describe('media actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }); + mockCreateSignedUrl.mockResolvedValue({ data: { signedUrl: 'https://example.com/file.png' } }); + mockRemove.mockResolvedValue({ data: null, error: null }); + mockAdminFrom.mockImplementation((table: string) => { + if (table === 'post_media') { + return createChainMock({ data: null, error: null }); + } + + return createChainMock({ data: null, error: null }); + }); + }); + + it('should revalidate the specific plan detail route after attaching media', async () => { + const result = await attachMediaToPost(['media-1'], 'post-1', 'plan-1'); + + expect(result.success).toBe(true); + expect(mockAdminFrom).toHaveBeenCalledWith('post_media'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/app/plan'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/app/plan/plan-1'); + }); + + it('should revalidate the specific plan detail route after deleting media', async () => { + mockFrom.mockImplementation((table: string) => { + if (table === 'post_media') { + return createChainMock({ data: { storage_path: 'user-1/media-1.png' }, error: null }); + } + + return createChainMock({ data: null, error: null }); + }); + + const result = await deletePostMedia('media-1', 'plan-1'); + + expect(result.success).toBe(true); + expect(mockRevalidatePath).toHaveBeenCalledWith('/app/plan'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/app/plan/plan-1'); + }); +}); diff --git a/actions/media.ts b/actions/media.ts index a08d157..81a6e5e 100644 --- a/actions/media.ts +++ b/actions/media.ts @@ -1,5 +1,7 @@ 'use server'; +import { revalidatePath } from 'next/cache'; + import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server'; const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB @@ -118,7 +120,10 @@ export async function uploadPostMedia(formData: FormData): Promise { +export async function deletePostMedia( + mediaId: string, + planId?: string, +): Promise { const supabase = await createServerClient(); const { data: { user } } = await supabase.auth.getUser(); @@ -142,5 +147,44 @@ export async function deletePostMedia(mediaId: string): Promise { + const supabase = await createServerClient(); + const admin = createServiceRoleClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) return { success: false, error: 'Unauthorized' }; + + const { error } = await admin + .from('post_media') + .update({ post_id: postId }) + .in('id', mediaIds) + .eq('user_id', user.id); + + if (error) { + return { success: false, error: 'Failed to attach media to post' }; + } + + revalidatePath('/app/plan'); + if (planId) { + revalidatePath(`/app/plan/${planId}`); + } + return { success: true }; } diff --git a/actions/publishing.test.ts b/actions/publishing.test.ts index 01215e3..d6c23ef 100644 --- a/actions/publishing.test.ts +++ b/actions/publishing.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockGetUser, mockFrom, mockRevalidatePath, mockPublishTweet, mockRefreshTwitterToken, mockPublishLinkedInPost, mockRefreshLinkedInToken } = vi.hoisted(() => ({ +const { mockGetUser, mockFrom, mockAdminFrom, mockRevalidatePath, mockPublishTweet, mockRefreshTwitterToken, mockPublishLinkedInPost, mockRefreshLinkedInToken } = vi.hoisted(() => ({ mockGetUser: vi.fn(), mockFrom: vi.fn(), + mockAdminFrom: vi.fn(), mockRevalidatePath: vi.fn(), mockPublishTweet: vi.fn(), mockRefreshTwitterToken: vi.fn(), @@ -15,6 +16,9 @@ vi.mock('@/lib/supabase/server', () => ({ auth: { getUser: mockGetUser }, from: mockFrom, }), + createServiceRoleClient: vi.fn(() => ({ + from: mockAdminFrom, + })), })); vi.mock('next/cache', () => ({ @@ -37,6 +41,7 @@ import { publishPost, schedulePost } from './publishing'; beforeEach(() => { vi.clearAllMocks(); + mockAdminFrom.mockReturnValue(createMockChain()); }); // --- Helpers --- @@ -301,7 +306,7 @@ describe('publishPost', () => { expect(result.results[0].platform).toBe('twitter'); expect(result.results[0].success).toBe(true); expect(result.results[0].externalUrl).toContain('twitter.com'); - expect(mockPublishTweet).toHaveBeenCalledWith('tw-access-token', 'Hello world', undefined, undefined); + expect(mockPublishTweet).toHaveBeenCalledWith('tw-access-token', 'Hello world', undefined, undefined, undefined); expect(postStatusUpdated).toBe(true); }); @@ -483,6 +488,7 @@ describe('publishPost', () => { expect.any(String), undefined, 'x-community-999', + undefined, ); }); @@ -552,7 +558,7 @@ describe('publishPost', () => { expect(result.success).toBe(true); expect(mockRefreshTwitterToken).toHaveBeenCalledWith('tw-refresh-token'); - expect(mockPublishTweet).toHaveBeenCalledWith('new-tw-token', 'Post with expired token', undefined, undefined); + expect(mockPublishTweet).toHaveBeenCalledWith('new-tw-token', 'Post with expired token', undefined, undefined, undefined); }); }); @@ -692,8 +698,8 @@ describe('schedulePost', () => { }); expect(mockPubInsert).toHaveBeenCalledWith([ - { post_id: 'post-1', platform_connection_id: TWITTER_CONNECTION.id, community_id: null, status: 'pending' }, - { post_id: 'post-1', platform_connection_id: LINKEDIN_CONNECTION.id, community_id: null, status: 'pending' }, + { post_id: 'post-1', platform_connection_id: TWITTER_CONNECTION.id, community_id: null, share_with_followers: false, status: 'pending' }, + { post_id: 'post-1', platform_connection_id: LINKEDIN_CONNECTION.id, community_id: null, share_with_followers: false, status: 'pending' }, ]); }); @@ -834,15 +840,19 @@ describe('schedulePost', () => { }), }; } - if (table === 'post_media') { - return { update: mockMediaUpdate }; - } if (table === 'post_publications') { return { insert: vi.fn().mockResolvedValue({ error: null }) }; } return createMockChain(); }); + mockAdminFrom.mockImplementation((table: string) => { + if (table === 'post_media') { + return { update: mockMediaUpdate }; + } + + return createMockChain(); + }); await schedulePost({ content: 'Post with media', diff --git a/actions/publishing.ts b/actions/publishing.ts index 1da0b0a..e383f1d 100644 --- a/actions/publishing.ts +++ b/actions/publishing.ts @@ -7,7 +7,7 @@ import { publishToPlatform, downloadMediaBuffer, } from '@/lib/publish-service'; -import { createServerClient } from '@/lib/supabase/server'; +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server'; import type { IConnectionRow, IMediaBuffer } from '@/lib/publish-service'; @@ -19,6 +19,7 @@ interface IPublishInput { platformConnectionIds: string[]; communityIds?: string[]; mediaIds?: string[]; + shareWithFollowers?: boolean; } interface IScheduleInput { @@ -28,6 +29,7 @@ interface IScheduleInput { communityIds?: string[]; mediaIds?: string[]; scheduledAt: string; + shareWithFollowers?: boolean; } interface IScheduleResult { @@ -60,6 +62,7 @@ interface IPublishResult { export async function publishPost(data: IPublishInput): Promise { const supabase = await createServerClient(); + const admin = createServiceRoleClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) { @@ -125,7 +128,7 @@ export async function publishPost(data: IPublishInput): Promise // Link media to post if (postId) { - await supabase + await admin .from('post_media') .update({ post_id: postId }) .in('id', data.mediaIds) @@ -154,6 +157,7 @@ export async function publishPost(data: IPublishInput): Promise conn: IConnectionRow; communityId?: string; communityRowId?: string; + shareWithFollowers?: boolean; } const tasks: IPublishTask[] = connRows.flatMap((conn) => { @@ -162,6 +166,7 @@ export async function publishPost(data: IPublishInput): Promise conn, communityId: c.community_id, communityRowId: c.id, + shareWithFollowers: data.shareWithFollowers, })); } @@ -176,12 +181,14 @@ export async function publishPost(data: IPublishInput): Promise data.content, mediaBuffers, task.communityId, + task.shareWithFollowers, ); await supabase.from('post_publications').insert({ post_id: postId, platform_connection_id: freshConn.id, community_id: task.communityId ?? null, + share_with_followers: task.shareWithFollowers ?? false, external_post_id: externalPostId, external_post_url: externalPostUrl, status: 'published', @@ -207,6 +214,7 @@ export async function publishPost(data: IPublishInput): Promise post_id: postId, platform_connection_id: task?.conn.id, community_id: task?.communityId ?? null, + share_with_followers: task?.shareWithFollowers ?? false, status: 'failed', error_message: errorMessage, }); @@ -245,6 +253,7 @@ export async function publishPost(data: IPublishInput): Promise export async function schedulePost(data: IScheduleInput): Promise { const supabase = await createServerClient(); + const admin = createServiceRoleClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) { @@ -301,7 +310,7 @@ export async function schedulePost(data: IScheduleInput): Promise 0) { - await supabase + await admin .from('post_media') .update({ post_id: postId }) .in('id', data.mediaIds) @@ -345,6 +354,7 @@ export async function schedulePost(data: IScheduleInput): Promise { if (twitterConnectionIds.has(connectionId) && communityRows.length > 0) { @@ -352,6 +362,7 @@ export async function schedulePost(data: IScheduleInput): Promise): Prom query = query.eq('status', 'scheduled'); } else if (filters.status === 'published') { query = query.eq('status', 'published'); + } else if (filters.status === 'draft') { + query = query.eq('status', 'draft'); } // 'failed' is handled post-query since it's a publication-level status + } else { + // By default, exclude draft posts — they belong to unscheduled content plans + query = query.neq('status', 'draft'); } // Apply date range filters diff --git a/app/app/page.tsx b/app/app/page.tsx index cd53ec6..770eb45 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -82,9 +82,12 @@ export default async function ComposerPage(): Promise { (item) => !item.isSystem || !deactivatedIds.has(item.id), ); - const spheres = activeItems.filter((i) => i.type === 'sphere'); + // Spheres & styles: only user-owned (added from template or custom-created) + // Raw Supabase rows have snake_case fields despite the IMemoryItem cast + const spheres = activeItems.filter((i) => i.type === 'sphere' && (i as unknown as Record).user_id !== null); + const styles = activeItems.filter((i) => i.type === 'style' && (i as unknown as Record).user_id !== null); + // Platforms: system + user-owned (system platforms always available) const platforms = activeItems.filter((i) => i.type === 'platform'); - const styles = activeItems.filter((i) => i.type === 'style'); const hasSubscription = subscriptionResult.data !== null; const maxVariations = subscriptionResult.data?.plans?.max_posts_per_generation ?? 1; diff --git a/app/app/plan/[planId]/loading.tsx b/app/app/plan/[planId]/loading.tsx new file mode 100644 index 0000000..fa3b6c1 --- /dev/null +++ b/app/app/plan/[planId]/loading.tsx @@ -0,0 +1,61 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +function PostSkeleton(): React.ReactElement { + return ( +
+
+ +
+ + + +
+
+ + + + +
+
+
+ ); +} + +export default function PlanDetailLoading(): React.ReactElement { + return ( +
+
+
+ + +
+ +
+ +
+
+ {Array.from({ length: 4 }, (_, dayIndex) => ( +
+
+ + {dayIndex < 3 && } +
+ +
+
+ + +
+ +
+ + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/app/app/plan/[planId]/page.tsx b/app/app/plan/[planId]/page.tsx index 20e0667..bb3b270 100644 --- a/app/app/plan/[planId]/page.tsx +++ b/app/app/plan/[planId]/page.tsx @@ -7,10 +7,14 @@ import { PlanIterationInput } from '@/components/content-plan/plan-iteration-inp import { SchedulePlanButton } from '@/components/content-plan/schedule-plan-button'; import { Button } from '@/components/ui/button'; import { PLAN_SLUGS } from '@/lib/constants'; -import { mapContentPlan, mapPlatformConnection, mapPost, mapTwitterCommunity } from '@/lib/mappers'; -import { createServerClient } from '@/lib/supabase/server'; +import { mapContentPlan, mapPlatformConnection, mapPost, mapPostMedia, mapTwitterCommunity } from '@/lib/mappers'; +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server'; -import type { IPost, ITwitterCommunity } from '@/types/database'; +import type { IPost, IPostMedia, ITwitterCommunity } from '@/types/database'; + +export interface IPostMediaWithUrl extends IPostMedia { + url: string; +} interface IPlanDetailPageProps { params: Promise<{ planId: string }>; @@ -66,6 +70,36 @@ export default async function PlanDetailPage({ params }: IPlanDetailPageProps): const posts = (postsData ?? []).map((row) => mapPost(row as Record)); + // Fetch all media for posts in this plan + const postIds = posts.map((p) => p.id); + const { data: mediaData } = postIds.length > 0 + ? await supabase + .from('post_media') + .select('*') + .in('post_id', postIds) + .order('display_order', { ascending: true }) + : { data: [] }; + + const allMedia = (mediaData ?? []).map((row) => mapPostMedia(row as Record)); + + // Generate signed URLs for media preview (private bucket, 1 hour expiry) + const admin = createServiceRoleClient(); + const mediaWithUrls: IPostMediaWithUrl[] = await Promise.all( + allMedia.map(async (m) => { + const { data: signedData } = await admin.storage + .from('post-media') + .createSignedUrl(m.storagePath, 3600); + return { ...m, url: signedData?.signedUrl ?? '' }; + }), + ); + + const mediaByPostId: Record = {}; + for (const media of mediaWithUrls) { + if (media.postId) { + (mediaByPostId[media.postId] ??= []).push(media); + } + } + const { data: platformData } = plan.platformId ? await supabase .from('memory_items') @@ -149,6 +183,7 @@ export default async function PlanDetailPage({ params }: IPlanDetailPageProps): dayName={getDayName(dayDate)} date={dayDate} posts={dayPosts} + mediaByPostId={mediaByPostId} planId={plan.id} characterLimit={characterLimit} connections={connections} diff --git a/app/app/plan/new/page.tsx b/app/app/plan/new/page.tsx index b8dec85..3b9c0b3 100644 --- a/app/app/plan/new/page.tsx +++ b/app/app/plan/new/page.tsx @@ -46,9 +46,12 @@ export default async function NewPlanPage(): Promise { const allItems = (itemsResult.data ?? []) as IMemoryItem[]; const creditBalance = (creditsResult.data?.balance as number | null) ?? 0; - const spheres = allItems.filter((i) => i.type === 'sphere'); + // Spheres & styles: only user-owned (added from template or custom-created) + // Raw Supabase rows have snake_case fields despite the IMemoryItem cast + const spheres = allItems.filter((i) => i.type === 'sphere' && (i as unknown as Record).user_id !== null); + const styles = allItems.filter((i) => i.type === 'style' && (i as unknown as Record).user_id !== null); + // Platforms: system + user-owned (system platforms always available) const platforms = allItems.filter((i) => i.type === 'platform'); - const styles = allItems.filter((i) => i.type === 'style'); return (
diff --git a/app/app/schedules/page.tsx b/app/app/schedules/page.tsx index b007e4a..8e13eb6 100644 --- a/app/app/schedules/page.tsx +++ b/app/app/schedules/page.tsx @@ -23,7 +23,7 @@ export default async function SchedulesPage({ const filters: Partial = { platform: (params.platform as ScheduleFiltersType['platform']) ?? undefined, status: (params.status as ScheduleFiltersType['status']) ?? undefined, - dateFrom: typeof params.dateFrom === 'string' ? params.dateFrom : undefined, + dateFrom: typeof params.dateFrom === 'string' ? params.dateFrom : new Date().toISOString().split('T')[0], dateTo: typeof params.dateTo === 'string' ? params.dateTo : undefined, }; diff --git a/app/app/settings/memory/page.tsx b/app/app/settings/memory/page.tsx index 69b35a7..7921ca3 100644 --- a/app/app/settings/memory/page.tsx +++ b/app/app/settings/memory/page.tsx @@ -78,11 +78,13 @@ export default async function MemoryPage(): Promise { const plan = (subscriptionResult.data as Record | null)?.plans as Record | null; - const planLimits = { - sphere: (plan?.max_spheres as number | null) ?? 2, - platform: (plan?.max_custom_platforms as number | null) ?? 0, - style: (plan?.max_custom_styles as number | null) ?? 0, - }; + const planLimits = plan + ? { + sphere: plan.max_spheres as number | null, + platform: plan.max_custom_platforms as number | null, + style: plan.max_custom_styles as number | null, + } + : { sphere: 2, platform: 0, style: 0 }; // Get global memory (user's own) const globalMemory = userItems.find((i) => i.type === 'global'); diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx new file mode 100644 index 0000000..7fae545 --- /dev/null +++ b/app/privacy/page.tsx @@ -0,0 +1,154 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +export const metadata: Metadata = { + title: 'Privacy Policy — Socio', + description: 'How Socio collects, uses, and protects your data.', +}; + +export default function PrivacyPolicyPage() { + return ( +
+
+ + ← Back to home + + +

Privacy Policy

+

Last updated: March 10, 2026

+ +
+
+

1. Who We Are

+

+ Socio (“we”, “us”, or “our”) is an AI-powered + social media content creation and scheduling platform. This Privacy Policy explains + how we collect, use, and protect information about you when you use our service. +

+
+ +
+

2. Information We Collect

+

We collect the following types of information:

+
    +
  • + Account information: Your name, email address, and password when + you register. +
  • +
  • + Social media tokens: OAuth access tokens for the social media + accounts you connect (Twitter/X, LinkedIn). These are stored encrypted and used + solely to publish content on your behalf. +
  • +
  • + Content and memory: Posts you draft, generate, or schedule, and + any memory items you save to personalize AI output. +
  • +
  • + Usage data: Pages visited, features used, and errors + encountered — used to improve the product. +
  • +
  • + Billing information: Managed by our payment processor (Polar). + We do not store raw card details. +
  • +
+
+ +
+

3. How We Use Your Information

+
    +
  • To provide and operate the Socio service.
  • +
  • To generate AI-powered content tailored to your voice and preferences.
  • +
  • To publish and schedule posts to your connected social accounts.
  • +
  • To process payments and manage your subscription.
  • +
  • To send transactional emails (account verification, billing receipts).
  • +
  • To improve the product through aggregated, anonymised analytics.
  • +
+
+ +
+

4. AI Processing

+

+ Content you create may be sent to third-party AI providers (OpenRouter, Google + Gemini) to generate responses. These providers process data under their own privacy + policies. We do not use your content to train AI models without your explicit + consent. +

+
+ +
+

5. Data Sharing

+

+ We do not sell your personal data. We share data only with service providers + necessary to operate Socio (hosting, AI inference, payments, email delivery), and + only to the extent required for those services. We may disclose data if required by + law. +

+
+ +
+

6. Data Retention

+

+ We retain your data for as long as your account is active. You may delete your + account at any time, after which your personal data is removed within 30 days, + except where retention is required by law. +

+
+ +
+

7. Your Rights

+

+ Depending on your location, you may have rights to access, correct, delete, or + export your personal data. To exercise any of these rights, contact us at the + email below. +

+
+ +
+

8. Cookies

+

+ We use strictly necessary cookies for authentication sessions. We do not use + advertising or tracking cookies. +

+
+ +
+

9. Security

+

+ We use industry-standard measures including encryption at rest and in transit, + and access controls to protect your data. No method of transmission over the + internet is 100% secure. +

+
+ +
+

10. Contact

+

+ For privacy-related questions, email us at{' '} + + privacy@socio.so + + . +

+
+
+ +
+ + Terms of Service + + + Home + +
+
+
+ ); +} diff --git a/app/terms/page.tsx b/app/terms/page.tsx new file mode 100644 index 0000000..d4b0679 --- /dev/null +++ b/app/terms/page.tsx @@ -0,0 +1,186 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +export const metadata: Metadata = { + title: 'Terms of Service — Socio', + description: 'Terms governing your use of the Socio platform.', +}; + +export default function TermsOfServicePage() { + return ( +
+
+ + ← Back to home + + +

Terms of Service

+

Last updated: March 10, 2026

+ +
+
+

1. Acceptance of Terms

+

+ By creating an account or using Socio, you agree to these Terms of Service. If you + do not agree, do not use the service. +

+
+ +
+

2. Description of Service

+

+ Socio is a subscription-based SaaS platform that uses AI to help you create, + schedule, and publish social media content to connected accounts (Twitter/X, + LinkedIn). +

+
+ +
+

3. Accounts

+
    +
  • You must be at least 18 years old to create an account.
  • +
  • You are responsible for keeping your credentials secure.
  • +
  • + You are responsible for all activity that occurs under your account, including + content published through connected social accounts. +
  • +
  • One person may not maintain multiple free accounts.
  • +
+
+ +
+

4. Subscriptions and Billing

+
    +
  • + Paid plans are billed monthly or annually as selected at checkout. Prices are + shown in USD and may be subject to applicable taxes. +
  • +
  • + Subscriptions renew automatically. You may cancel at any time; access continues + until the end of the current billing period. +
  • +
  • + We do not offer refunds for partial billing periods unless required by applicable + law. +
  • +
  • + We reserve the right to change pricing with 30 days’ notice. Continued use + after the effective date constitutes acceptance. +
  • +
+
+ +
+

5. Acceptable Use

+

You agree not to use Socio to:

+
    +
  • Post spam, misleading content, or content that violates platform policies.
  • +
  • + Harass, defame, or threaten others, or publish content that is illegal in your + jurisdiction. +
  • +
  • Attempt to reverse-engineer, scrape, or exploit the service.
  • +
  • + Share account credentials or resell access to the platform without authorisation. +
  • +
+

+ We reserve the right to suspend or terminate accounts that violate these terms. +

+
+ +
+

6. AI-Generated Content

+

+ Socio uses AI models to generate content suggestions. You are solely responsible + for reviewing and approving any content before it is published. We make no + guarantees about the accuracy, completeness, or suitability of AI-generated + content. +

+
+ +
+

7. Intellectual Property

+

+ You retain ownership of all content you create using Socio. You grant us a limited + licence to process and store your content solely to provide the service. The Socio + platform, branding, and software remain our exclusive property. +

+
+ +
+

8. Third-Party Services

+

+ Socio integrates with third-party platforms (Twitter/X, LinkedIn, Google, + OpenRouter, Polar). Your use of those platforms is governed by their own terms. + We are not responsible for their actions or availability. +

+
+ +
+

9. Disclaimers and Limitation of Liability

+

+ The service is provided “as is” without warranties of any kind. To the + fullest extent permitted by law, we are not liable for any indirect, incidental, or + consequential damages arising from your use of Socio, including loss of data, + revenue, or social media access. Our total liability to you in any calendar month + shall not exceed the amount you paid us in that month. +

+
+ +
+

10. Termination

+

+ You may delete your account at any time. We may suspend or terminate your account + for breach of these terms, with or without notice. Upon termination, your data will + be deleted in accordance with our Privacy Policy. +

+
+ +
+

11. Changes to Terms

+

+ We may update these terms from time to time. We will notify you by email or + in-app notice at least 14 days before material changes take effect. Continued use + after the effective date constitutes acceptance. +

+
+ +
+

12. Governing Law

+

+ These terms are governed by the laws of the jurisdiction in which we are + incorporated, without regard to conflict of law principles. +

+
+ +
+

13. Contact

+

+ For questions about these terms, email us at{' '} + + legal@socio.so + + . +

+
+
+ +
+ + Privacy Policy + + + Home + +
+
+
+ ); +} diff --git a/assets/Function _ 100 Healthy Years.jpeg b/assets/Function _ 100 Healthy Years.jpeg new file mode 100644 index 0000000..9e7b6b5 Binary files /dev/null and b/assets/Function _ 100 Healthy Years.jpeg differ diff --git a/assets/Screenshot 2026-03-03 at 14.26.47.png b/assets/Screenshot 2026-03-03 at 14.26.47.png new file mode 100644 index 0000000..c590f89 Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.26.47.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.27.01.png b/assets/Screenshot 2026-03-03 at 14.27.01.png new file mode 100644 index 0000000..cfed93d Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.27.01.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.27.12.png b/assets/Screenshot 2026-03-03 at 14.27.12.png new file mode 100644 index 0000000..6687b16 Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.27.12.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.27.24.png b/assets/Screenshot 2026-03-03 at 14.27.24.png new file mode 100644 index 0000000..33dab29 Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.27.24.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.27.32.png b/assets/Screenshot 2026-03-03 at 14.27.32.png new file mode 100644 index 0000000..0e139c6 Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.27.32.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.27.40.png b/assets/Screenshot 2026-03-03 at 14.27.40.png new file mode 100644 index 0000000..fa1c137 Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.27.40.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.27.50.png b/assets/Screenshot 2026-03-03 at 14.27.50.png new file mode 100644 index 0000000..4fcc867 Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.27.50.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.27.59.png b/assets/Screenshot 2026-03-03 at 14.27.59.png new file mode 100644 index 0000000..bc4ccd8 Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.27.59.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.28.08.png b/assets/Screenshot 2026-03-03 at 14.28.08.png new file mode 100644 index 0000000..9c0c60d Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.28.08.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.28.39.png b/assets/Screenshot 2026-03-03 at 14.28.39.png new file mode 100644 index 0000000..d2a89f6 Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.28.39.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.28.49.png b/assets/Screenshot 2026-03-03 at 14.28.49.png new file mode 100644 index 0000000..96e50e9 Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.28.49.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.28.58.png b/assets/Screenshot 2026-03-03 at 14.28.58.png new file mode 100644 index 0000000..8b8cc9e Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.28.58.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.29.14.png b/assets/Screenshot 2026-03-03 at 14.29.14.png new file mode 100644 index 0000000..9169654 Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.29.14.png differ diff --git a/assets/Screenshot 2026-03-03 at 14.30.47.png b/assets/Screenshot 2026-03-03 at 14.30.47.png new file mode 100644 index 0000000..0c688cb Binary files /dev/null and b/assets/Screenshot 2026-03-03 at 14.30.47.png differ diff --git a/components/composer/composer.tsx b/components/composer/composer.tsx index d97e085..4fda345 100644 --- a/components/composer/composer.tsx +++ b/components/composer/composer.tsx @@ -1,10 +1,12 @@ 'use client'; -import { useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { AnimatePresence, motion } from 'framer-motion'; import { Sparkles, Send, ImagePlus } from 'lucide-react'; + +import { TextAnimate } from '@/components/ui/text-animate'; import { toast } from 'sonner'; import { generateVariations } from '@/actions/generation'; @@ -19,6 +21,26 @@ import PublishModal from '@/components/publishing/publish-modal'; import type { IMediaFile } from '@/components/composer/media-preview'; import type { IMemoryItem, IPlatformConnection, ITwitterCommunity } from '@/types/database'; +const EASE = [0.22, 1, 0.36, 1] as const; + +function reveal(delay: number) { + return { + initial: { opacity: 0, filter: 'blur(10px)', y: 20 }, + animate: { opacity: 1, filter: 'blur(0px)', y: 0 }, + transition: { delay, duration: 0.6, ease: EASE }, + }; +} + +const GREETINGS = [ + (name: string | null) => name ? `Let's get viral, ${name}!` : "Let's get viral!", + (name: string | null) => name ? `Ready to post, ${name}?` : 'Ready to post?', + (name: string | null) => name ? `What's on your mind, ${name}?` : "What's on your mind?", + (name: string | null) => name ? `Time to create, ${name}!` : 'Time to create!', + (name: string | null) => name ? `Let's make waves, ${name}!` : "Let's make waves!", + (name: string | null) => name ? `Your audience awaits, ${name}!` : 'Your audience awaits!', + (name: string | null) => name ? `Inspire the feed, ${name}!` : 'Inspire the feed!', +]; + interface IComposerProps { spheres: IMemoryItem[]; platforms: IMemoryItem[]; @@ -133,7 +155,10 @@ export default function Composer({ } const hasVariations = variations.length > 0; - const greeting = firstName ? `Let's get viral ${firstName}!` : "Let's get viral!"; + const greeting = useMemo( + () => GREETINGS[Math.floor(Math.random() * GREETINGS.length)](firstName), + [firstName], + ); return (
@@ -154,13 +179,27 @@ export default function Composer({ {/* Greeting -- visible when no variations and not generating */} {!hasVariations && !isGenerating && ( -

- {greeting} -

+ + + {greeting} + + )} {/* Post form -- narrower container */} -
+ {/* Reserved slot — height always present, content slides in from below */}
@@ -170,7 +209,7 @@ export default function Composer({ initial={{ opacity: 0, y: 24, filter: 'blur(6px)' }} animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }} exit={{ opacity: 0, y: 24, filter: 'blur(6px)' }} - transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }} + transition={{ duration: 0.4, ease: EASE }} > @@ -249,7 +288,7 @@ export default function Composer({
-
+
diff --git a/components/content-plan/plan-day-section.tsx b/components/content-plan/plan-day-section.tsx index 5051889..e83a27a 100644 --- a/components/content-plan/plan-day-section.tsx +++ b/components/content-plan/plan-day-section.tsx @@ -2,12 +2,14 @@ import { PlanPostCard } from './plan-post-card'; +import type { IPostMediaWithUrl } from '@/app/app/plan/[planId]/page'; import type { IPlatformConnection, IPost, ITwitterCommunity } from '@/types/database'; interface IPlanDaySectionProps { dayName: string; date: Date; posts: IPost[]; + mediaByPostId: Record; planId: string; characterLimit?: number; connections: IPlatformConnection[]; @@ -23,6 +25,7 @@ export function PlanDaySection({ dayName, date, posts, + mediaByPostId, planId, characterLimit, connections, @@ -54,6 +57,7 @@ export function PlanDaySection({ ({ + mockRegeneratePlan: vi.fn(), +})); + +vi.mock('@/actions/content-plan', () => ({ + regeneratePlan: mockRegeneratePlan, +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { PlanIterationInput } from './plan-iteration-input'; + +describe('PlanIterationInput', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should show generating indicator while the plan is regenerating', async () => { + let resolvePromise: ((value: { success: boolean; data: { posts: unknown[]; creditsUsed: number } }) => void) | undefined; + mockRegeneratePlan.mockImplementation(() => new Promise((resolve) => { + resolvePromise = resolve; + })); + + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: 'Regenerate' })); + await user.type(screen.getByPlaceholderText(/make posts shorter/i), 'Make them sharper'); + await user.click(screen.getByRole('button', { name: 'Submit plan refinement' })); + + expect(screen.getByTestId('generating-indicator')).toBeInTheDocument(); + + await act(async () => { + resolvePromise?.({ success: true, data: { posts: [], creditsUsed: 1 } }); + }); + }); +}); diff --git a/components/content-plan/plan-iteration-input.tsx b/components/content-plan/plan-iteration-input.tsx index e8a2420..e140ef3 100644 --- a/components/content-plan/plan-iteration-input.tsx +++ b/components/content-plan/plan-iteration-input.tsx @@ -5,6 +5,7 @@ import { useRef, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { regeneratePlan } from '@/actions/content-plan'; +import GeneratingIndicator from '@/components/composer/generating-indicator'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -15,6 +16,12 @@ interface IPlanIterationInputProps { const MIN_HEIGHT = 44; const MAX_HEIGHT = 120; +const REGENERATING_LABELS = [ + 'Refreshing your content plan...', + 'Rewriting posts with your feedback...', + 'Adjusting the plan to match your notes...', + 'Generating a better version of your plan...', +]; export function PlanIterationInput({ planId, isActive }: IPlanIterationInputProps): React.ReactElement { const [isOpen, setIsOpen] = useState(false); @@ -79,7 +86,11 @@ export function PlanIterationInput({ planId, isActive }: IPlanIterationInputProp >
- Refine your plan + {isIterating ? ( + + ) : ( + Refine your plan + )} @@ -100,6 +111,7 @@ export function PlanIterationInput({ planId, isActive }: IPlanIterationInputProp )} />
- {isIterating && ( -

Regenerating all posts with your feedback...

- )}
diff --git a/components/content-plan/plan-post-card.test.tsx b/components/content-plan/plan-post-card.test.tsx index 42a47a6..c5fe859 100644 --- a/components/content-plan/plan-post-card.test.tsx +++ b/components/content-plan/plan-post-card.test.tsx @@ -1,10 +1,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +const { + mockRefresh, + mockUploadPostMedia, + mockAttachMediaToPost, + mockDeletePostMedia, +} = vi.hoisted(() => ({ + mockRefresh: vi.fn(), + mockUploadPostMedia: vi.fn(), + mockAttachMediaToPost: vi.fn(), + mockDeletePostMedia: vi.fn(), +})); + // jsdom doesn't implement scrollIntoView Element.prototype.scrollIntoView = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: mockRefresh }), +})); + import * as contentPlanActions from '@/actions/content-plan'; import { PlanPostCard } from './plan-post-card'; @@ -26,7 +42,9 @@ vi.mock('@/actions/publishing', () => ({ })); vi.mock('@/actions/media', () => ({ - uploadPostMedia: vi.fn(), + uploadPostMedia: mockUploadPostMedia, + attachMediaToPost: mockAttachMediaToPost, + deletePostMedia: mockDeletePostMedia, })); const MOCK_DRAFT_POST: IPost = { @@ -81,6 +99,9 @@ const TWITTER_CONNECTION: IPlatformConnection = { beforeEach(() => { vi.clearAllMocks(); + mockUploadPostMedia.mockResolvedValue({ success: true, data: [] }); + mockAttachMediaToPost.mockResolvedValue({ success: true }); + mockDeletePostMedia.mockResolvedValue({ success: true }); }); describe('PlanPostCard', () => { @@ -88,6 +109,7 @@ describe('PlanPostCard', () => { render( , @@ -100,6 +122,7 @@ describe('PlanPostCard', () => { render( , @@ -112,6 +135,7 @@ describe('PlanPostCard', () => { render( , @@ -126,6 +150,7 @@ describe('PlanPostCard', () => { render( , @@ -143,6 +168,7 @@ describe('PlanPostCard', () => { render( , @@ -172,6 +198,7 @@ describe('PlanPostCard', () => { render( , @@ -187,4 +214,81 @@ describe('PlanPostCard', () => { }), ); }); + + it('should render action buttons in static mode', () => { + render( + , + ); + + const actions = screen.getByTestId('plan-post-actions'); + expect(actions.className).not.toContain('opacity-0'); + expect(actions.className).not.toContain('group-hover:opacity-100'); + }); + + it('should refresh the plan page after successful media upload', async () => { + const user = userEvent.setup(); + const file = new File(['hello'], 'hero.png', { type: 'image/png' }); + mockUploadPostMedia.mockResolvedValue({ + success: true, + data: [{ + id: 'media-1', + storagePath: 'user-1/media-1.png', + fileName: 'hero.png', + mimeType: 'image/png', + fileSize: 5, + url: 'https://example.com/hero.png', + }], + }); + + const { container } = render( + , + ); + + const input = container.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + + await user.upload(input as HTMLInputElement, file); + + expect(mockAttachMediaToPost).toHaveBeenCalledWith(['media-1'], 'post-1', 'plan-1'); + await waitFor(() => expect(mockRefresh).toHaveBeenCalled()); + }); + + it('should refresh the plan page after successful media delete', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByLabelText('Remove hero.png')); + + expect(mockDeletePostMedia).toHaveBeenCalledWith('media-1', 'plan-1'); + await waitFor(() => expect(mockRefresh).toHaveBeenCalled()); + }); }); diff --git a/components/content-plan/plan-post-card.tsx b/components/content-plan/plan-post-card.tsx index 15e38e8..7938452 100644 --- a/components/content-plan/plan-post-card.tsx +++ b/components/content-plan/plan-post-card.tsx @@ -1,9 +1,11 @@ 'use client'; -import { Check, Pencil, Send, Sparkles, Trash2, X } from 'lucide-react'; -import { useState } from 'react'; +import { Check, FileVideo, ImagePlus, Loader2, Pencil, Send, Sparkles, Trash2, X } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useRef, useState } from 'react'; import { toast } from 'sonner'; +import { attachMediaToPost, deletePostMedia, uploadPostMedia } from '@/actions/media'; import { deletePlanPost, regeneratePost, updatePlanPost } from '@/actions/content-plan'; import { PostDateTimePicker } from '@/components/content-plan/post-date-time-picker'; import PublishModal from '@/components/publishing/publish-modal'; @@ -14,16 +16,26 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; +import type { IPostMediaWithUrl } from '@/app/app/plan/[planId]/page'; import type { IPlatformConnection, IPost, ITwitterCommunity } from '@/types/database'; +// ─── Constants ─────────────────────────────────────────────────────────────── + +const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4']; + +// ─── Props ──────────────────────────────────────────────────────────────────── + interface IPlanPostCardProps { post: IPost; + media: IPostMediaWithUrl[]; planId: string; characterLimit?: number; connections: IPlatformConnection[]; communities?: ITwitterCommunity[]; } +// ─── Helpers ───────────────────────────────────────────────────────────────── + function getCharCountColor(length: number, limit: number | undefined): string { if (!limit) return 'text-muted-foreground'; if (length > limit) return 'text-destructive'; @@ -31,20 +43,26 @@ function getCharCountColor(length: number, limit: number | undefined): string { return 'text-muted-foreground'; } +// ─── Component ─────────────────────────────────────────────────────────────── + export function PlanPostCard({ post, + media, planId, characterLimit, connections, communities = [], }: IPlanPostCardProps): React.ReactElement { + const router = useRouter(); const [isEditing, setIsEditing] = useState(false); const [isEnhancing, setIsEnhancing] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [isUploading, setIsUploading] = useState(false); const [isPublishOpen, setIsPublishOpen] = useState(false); const [editContent, setEditContent] = useState(post.content); const [enhanceComment, setEnhanceComment] = useState(''); const [isAiOpen, setIsAiOpen] = useState(false); + const fileInputRef = useRef(null); const isDraft = post.status === 'draft'; const charCount = post.content.length; @@ -93,6 +111,50 @@ export function PlanPostCard({ } } + async function handleFileChange(e: React.ChangeEvent): Promise { + const files = Array.from(e.target.files ?? []); + if (files.length === 0) return; + + // Reset input so the same file can be selected again + e.target.value = ''; + + setIsUploading(true); + try { + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + const uploadResult = await uploadPostMedia(formData); + if (!uploadResult.success || !uploadResult.data) { + toast.error(uploadResult.error ?? 'Upload failed'); + return; + } + + const mediaIds = uploadResult.data.map((m) => m.id); + const attachResult = await attachMediaToPost(mediaIds, post.id, planId); + if (!attachResult.success) { + toast.error(attachResult.error ?? 'Failed to attach media'); + return; + } + + toast.success(`${files.length} file${files.length > 1 ? 's' : ''} uploaded`); + router.refresh(); + } finally { + setIsUploading(false); + } + } + + async function handleDeleteMedia(mediaId: string): Promise { + const result = await deletePostMedia(mediaId, planId); + if (!result.success) { + toast.error(result.error ?? 'Failed to delete media'); + return; + } + + router.refresh(); + } + if (isEditing) { return (
@@ -139,10 +201,48 @@ export function PlanPostCard({ )}
-

{post.content}

+

{post.content}

+ + {/* Attached media thumbnails */} + {media.length > 0 && ( +
+ {media.map((m) => ( +
+ {m.mimeType.startsWith('image/') ? ( +
+ {m.url ? ( + {m.fileName} + ) : ( +
+ +
+ )} +
+ ) : ( +
+ +
+ )} + {isDraft && ( + + )} +
+ ))} +
+ )} - {/* Action buttons — visible on hover, pinned to the bottom */} -
+
{isDraft && ( + {/* File upload */} + {isDraft && ( + <> + + + + )} + - - - - Schedule All Posts - - Select the platforms where you want to publish all draft posts at their scheduled times. - - - -
- {connections.map((connection) => ( -
- handleToggle(connection.id)} - /> - -
- ))} -
- - {connections.length === 0 && ( -

- No platforms connected. Connect your accounts in Settings. -

- )} - - {isTwitterSelected && communities.length > 0 && ( - <> - -
-
- Post to communities - (optional) -
- {communities.map((community) => ( -
- handleCommunityToggle(community.id)} - /> - -
- ))} -
- - )} - - - - - -
- + <> + + + setIsOpen(false)} + /> + ); } diff --git a/components/publishing/community-select.test.tsx b/components/publishing/community-select.test.tsx index 27435a2..517d9af 100644 --- a/components/publishing/community-select.test.tsx +++ b/components/publishing/community-select.test.tsx @@ -34,14 +34,21 @@ const mockCommunities: ITwitterCommunity[] = [ }, ]; +const defaultProps = { + communities: mockCommunities, + selectedIds: new Set(), + isTwitterSelected: true, + shareWithFollowers: false, + onToggle: vi.fn(), + onShareWithFollowersChange: vi.fn(), +}; + describe('CommunitySelect', () => { it('should not render when twitter is not selected', () => { const { container } = render( , ); @@ -49,28 +56,14 @@ describe('CommunitySelect', () => { }); it('should render community options when twitter is selected', () => { - render( - , - ); + render(); expect(screen.getByText('Build in Public')).toBeInTheDocument(); expect(screen.getByText('Indie Hackers')).toBeInTheDocument(); }); it('should show label and optional hint', () => { - render( - , - ); + render(); expect(screen.getByText(/post to communities/i)).toBeInTheDocument(); expect(screen.getByText(/optional/i)).toBeInTheDocument(); @@ -79,14 +72,7 @@ describe('CommunitySelect', () => { it('should call onToggle with community id when clicked', async () => { const handleToggle = vi.fn(); - render( - , - ); + render(); await userEvent.click(screen.getByText('Build in Public')); @@ -96,13 +82,12 @@ describe('CommunitySelect', () => { it('should show checked state for selected communities', () => { render( , ); + // community checkboxes only (no share-with-followers visible yet — c1 is checked) const checkboxes = screen.getAllByRole('checkbox'); expect(checkboxes[0]).toBeChecked(); expect(checkboxes[1]).not.toBeChecked(); @@ -111,13 +96,57 @@ describe('CommunitySelect', () => { it('should render empty state when no communities saved', () => { render( , ); expect(screen.getByText(/no communities/i)).toBeInTheDocument(); }); + + it('should show share with followers checkbox when a community is selected', () => { + render( + , + ); + + expect(screen.getByText(/share with followers/i)).toBeInTheDocument(); + }); + + it('should not show share with followers checkbox when no community is selected', () => { + render(); + + expect(screen.queryByText(/share with followers/i)).not.toBeInTheDocument(); + }); + + it('should call onShareWithFollowersChange when share checkbox clicked', async () => { + const handleChange = vi.fn(); + + render( + , + ); + + await userEvent.click(screen.getByText(/share with followers/i)); + + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it('should show share with followers checkbox as checked when enabled', () => { + render( + , + ); + + const shareCheckbox = screen.getByRole('checkbox', { name: /share with followers/i }); + expect(shareCheckbox).toBeChecked(); + }); }); diff --git a/components/publishing/community-select.tsx b/components/publishing/community-select.tsx index 365a18f..344ff1b 100644 --- a/components/publishing/community-select.tsx +++ b/components/publishing/community-select.tsx @@ -10,7 +10,9 @@ interface ICommunitySelectProps { communities: ITwitterCommunity[]; selectedIds: Set; isTwitterSelected: boolean; + shareWithFollowers: boolean; onToggle: (id: string) => void; + onShareWithFollowersChange: (value: boolean) => void; } // ─── Component ──────────────────────────────────────────────────────────────── @@ -19,10 +21,14 @@ export default function CommunitySelect({ communities, selectedIds, isTwitterSelected, + shareWithFollowers, onToggle, + onShareWithFollowersChange, }: ICommunitySelectProps): React.ReactElement | null { if (!isTwitterSelected) return null; + const hasSelectedCommunities = selectedIds.size > 0; + return (
@@ -64,6 +70,25 @@ export default function CommunitySelect({ ))}
)} + + {hasSelectedCommunities && ( + + )}
); } diff --git a/components/publishing/publish-modal.test.tsx b/components/publishing/publish-modal.test.tsx index 567284b..b773479 100644 --- a/components/publishing/publish-modal.test.tsx +++ b/components/publishing/publish-modal.test.tsx @@ -3,21 +3,27 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import PublishModal from './publish-modal'; -import type { IPlatformConnection } from '@/types/database'; +import type { IPlatformConnection, ITwitterCommunity } from '@/types/database'; -const { mockPublishPost, mockUploadPostMedia } = vi.hoisted(() => ({ +const { mockPublishPost, mockUploadPostMedia, mockSchedulePlan } = vi.hoisted(() => ({ mockPublishPost: vi.fn(), mockUploadPostMedia: vi.fn(), + mockSchedulePlan: vi.fn(), })); vi.mock('@/actions/publishing', () => ({ publishPost: mockPublishPost, + schedulePost: vi.fn(), })); vi.mock('@/actions/media', () => ({ uploadPostMedia: mockUploadPostMedia, })); +vi.mock('@/actions/content-plan', () => ({ + schedulePlan: mockSchedulePlan, +})); + vi.mock('sonner', () => ({ toast: { success: vi.fn(), @@ -58,6 +64,20 @@ const LINKEDIN_CONNECTION: IPlatformConnection = { updatedAt: '2025-01-01', }; +const TWITTER_COMMUNITY: ITwitterCommunity = { + id: 'comm-1', + userId: 'user-1', + communityId: 'tc-111', + name: 'Build in Public', + description: null, + memberCount: 5000, + bannerUrl: null, + joinPolicy: 'Open', + isNsfw: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +}; + beforeEach(() => { vi.clearAllMocks(); }); @@ -139,7 +159,6 @@ describe('PublishModal', () => { />, ); - // Uncheck the pre-selected Twitter checkbox await user.click(screen.getAllByRole('checkbox')[0]); const publishButton = screen.getByRole('button', { name: /publish now/i }); @@ -175,11 +194,13 @@ describe('PublishModal', () => { await user.click(publishButton); await waitFor(() => { - expect(mockPublishPost).toHaveBeenCalledWith({ - content: 'Hello world', - postId: undefined, - platformConnectionIds: [TWITTER_CONNECTION.id], - }); + expect(mockPublishPost).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Hello world', + postId: undefined, + platformConnectionIds: [TWITTER_CONNECTION.id], + }), + ); }); await waitFor(() => { @@ -236,4 +257,155 @@ describe('PublishModal', () => { ); }); }); + + it('should pass shareWithFollowers to publishPost when community selected and checkbox checked', async () => { + const user = userEvent.setup(); + + mockPublishPost.mockResolvedValue({ + success: true, + results: [{ platform: 'twitter', success: true }], + }); + + render( + , + ); + + // Select the community + await user.click(screen.getByText('Build in Public')); + + // Wait for share with followers checkbox to appear, then check it + const shareLabel = await screen.findByText(/share with followers/i); + await user.click(shareLabel); + + await user.click(screen.getByRole('button', { name: /publish now/i })); + + await waitFor(() => { + expect(mockPublishPost).toHaveBeenCalledWith( + expect.objectContaining({ + communityIds: ['comm-1'], + shareWithFollowers: true, + }), + ); + }); + }); + + it('should not pass shareWithFollowers when no community selected', async () => { + const user = userEvent.setup(); + + mockPublishPost.mockResolvedValue({ + success: true, + results: [{ platform: 'twitter', success: true }], + }); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /publish now/i })); + + await waitFor(() => { + expect(mockPublishPost).toHaveBeenCalledWith( + expect.objectContaining({ + communityIds: undefined, + shareWithFollowers: undefined, + }), + ); + }); + }); + + describe('schedulePlan mode', () => { + it('should show "Schedule All Posts" title in schedulePlan mode', () => { + render( + , + ); + + expect(screen.getByText('Schedule All Posts')).toBeInTheDocument(); + }); + + it('should hide post preview in schedulePlan mode', () => { + render( + , + ); + + expect(screen.queryByText('some content')).not.toBeInTheDocument(); + }); + + it('should hide schedule toggle in schedulePlan mode', () => { + render( + , + ); + + expect(screen.queryByText(/schedule for later/i)).not.toBeInTheDocument(); + }); + + it('should call schedulePlan when Schedule Plan button clicked', async () => { + const user = userEvent.setup(); + + mockSchedulePlan.mockResolvedValue({ + success: true, + data: { scheduledCount: 3 }, + }); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /schedule plan/i })); + + await waitFor(() => { + expect(mockSchedulePlan).toHaveBeenCalledWith( + expect.objectContaining({ + planId: 'plan-1', + platformConnectionIds: [TWITTER_CONNECTION.id], + }), + ); + }); + }); + }); }); diff --git a/components/publishing/publish-modal.tsx b/components/publishing/publish-modal.tsx index 3bd247d..42e570b 100644 --- a/components/publishing/publish-modal.tsx +++ b/components/publishing/publish-modal.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner'; import { uploadPostMedia } from '@/actions/media'; import { publishPost, schedulePost } from '@/actions/publishing'; +import { schedulePlan } from '@/actions/content-plan'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -39,9 +40,11 @@ const KNOWN_PLATFORMS = [ interface IPublishModalProps { content: string; postId?: string; + planId?: string; pendingFiles?: File[]; connections: IPlatformConnection[]; communities?: ITwitterCommunity[]; + mode?: 'publish' | 'schedulePlan'; isOpen: boolean; onOpenChange: (open: boolean) => void; onPublished: () => void; @@ -52,9 +55,11 @@ interface IPublishModalProps { export default function PublishModal({ content, postId, + planId, pendingFiles, connections, communities = [], + mode = 'publish', isOpen, onOpenChange, onPublished, @@ -65,6 +70,7 @@ export default function PublishModal({ return new Set(connectedIds); }); const [selectedCommunityIds, setSelectedCommunityIds] = useState>(new Set()); + const [shareWithFollowers, setShareWithFollowers] = useState(false); const [isPublishing, setIsPublishing] = useState(false); const [isScheduling, setIsScheduling] = useState(false); const [scheduleDate, setScheduleDate] = useState(getTomorrowDate); @@ -75,6 +81,7 @@ export default function PublishModal({ : content; const hasSelection = selectedIds.size > 0; + const isSchedulePlanMode = mode === 'schedulePlan'; const isTwitterSelected = connections.some( (c) => c.platform === 'twitter' && selectedIds.has(c.id), @@ -108,11 +115,39 @@ export default function PublishModal({ }); } + async function handleSchedulePlan(): Promise { + if (!planId) return; + + setIsPublishing(true); + + try { + const result = await schedulePlan({ + planId, + platformConnectionIds: Array.from(selectedIds), + communityIds: selectedCommunityIds.size > 0 ? Array.from(selectedCommunityIds) : undefined, + shareWithFollowers: shareWithFollowers || undefined, + }); + + if (result.success) { + toast.success(`Scheduled ${result.data?.scheduledCount ?? 0} posts`); + onPublished(); + onOpenChange(false); + } else { + toast.error(result.error ?? 'Scheduling failed'); + } + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } finally { + setIsPublishing(false); + } + } + async function handlePublish(): Promise { setIsPublishing(true); try { - // Upload pending files to storage first let mediaIds: string[] | undefined; if (pendingFiles && pendingFiles.length > 0) { @@ -134,7 +169,6 @@ export default function PublishModal({ } if (isScheduling) { - // Schedule the post const scheduledAt = `${scheduleDate}T${scheduleTime}:00`; const result = await schedulePost({ @@ -144,6 +178,7 @@ export default function PublishModal({ communityIds: selectedCommunityIds.size > 0 ? Array.from(selectedCommunityIds) : undefined, mediaIds, scheduledAt, + shareWithFollowers: shareWithFollowers || undefined, }); if (result.success) { @@ -160,13 +195,13 @@ export default function PublishModal({ toast.error(result.error ?? 'Scheduling failed'); } } else { - // Publish immediately const result = await publishPost({ content, postId, platformConnectionIds: Array.from(selectedIds), communityIds: selectedCommunityIds.size > 0 ? Array.from(selectedCommunityIds) : undefined, mediaIds, + shareWithFollowers: shareWithFollowers || undefined, }); if (result.success) { @@ -201,20 +236,38 @@ export default function PublishModal({ } } + function handleAction(): Promise { + if (isSchedulePlanMode) { + return handleSchedulePlan(); + } + + return handlePublish(); + } + + const actionLabel = isSchedulePlanMode ? 'Schedule Plan' : isScheduling ? 'Schedule' : 'Publish Now'; + const loadingLabel = isSchedulePlanMode ? 'Scheduling...' : isScheduling ? 'Scheduling...' : 'Publishing...'; + const actionIcon = isSchedulePlanMode || isScheduling ? : ; + return ( - Publish to platforms + + {isSchedulePlanMode ? 'Schedule All Posts' : 'Publish to platforms'} + - Select the platforms you want to publish this post to. + {isSchedulePlanMode + ? 'Select the platforms where you want to publish all draft posts at their scheduled times.' + : 'Select the platforms you want to publish this post to.'} - {/* Post preview */} -
-

{preview}

-
+ {/* Post preview — only in publish mode */} + {!isSchedulePlanMode && ( +
+

{preview}

+
+ )} {/* Platform list */}
@@ -245,29 +298,34 @@ export default function PublishModal({ communities={communities} selectedIds={selectedCommunityIds} isTwitterSelected={isTwitterSelected} + shareWithFollowers={shareWithFollowers} onToggle={handleCommunityToggle} + onShareWithFollowersChange={setShareWithFollowers} /> - {/* Schedule toggle */} -
- - -
- - {/* Schedule picker */} - {isScheduling && ( - + {/* Schedule toggle — only in publish mode */} + {!isSchedulePlanMode && ( + <> +
+ + +
+ + {isScheduling && ( + + )} + )} @@ -279,23 +337,18 @@ export default function PublishModal({ Cancel diff --git a/components/schedules/schedule-filters.tsx b/components/schedules/schedule-filters.tsx index 3a20f28..7762f1a 100644 --- a/components/schedules/schedule-filters.tsx +++ b/components/schedules/schedule-filters.tsx @@ -30,6 +30,7 @@ const STATUS_OPTIONS = [ { value: 'pending', label: 'Pending' }, { value: 'published', label: 'Published' }, { value: 'failed', label: 'Failed' }, + { value: 'draft', label: 'Draft' }, ] as const; // ─── Component ─────────────────────────────────────────────────────────────── @@ -76,7 +77,12 @@ export default function ScheduleFilters(): React.ReactElement { } const hasActiveFilters = platform !== 'all' || status !== 'all' || dateFrom !== '' || dateTo !== ''; - const dateFromDisplay = dateFrom ? format(new Date(`${dateFrom}T00:00:00`), 'MMM d') : 'From'; + const isImplicitToday = !searchParams.has('dateFrom'); + const dateFromDisplay = dateFrom + ? format(new Date(`${dateFrom}T00:00:00`), 'MMM d') + : isImplicitToday + ? 'Today' + : 'From'; const dateToDisplay = dateTo ? format(new Date(`${dateTo}T00:00:00`), 'MMM d') : 'To'; return ( @@ -117,7 +123,7 @@ export default function ScheduleFilters(): React.ReactElement {
- +