Added blog writer implementation - WIP
This commit is contained in:
256
backend/BILLING_SYSTEM_INTEGRATION.md
Normal file
256
backend/BILLING_SYSTEM_INTEGRATION.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# ALwrity Billing & Subscription System Integration
|
||||
|
||||
## Overview
|
||||
|
||||
The ALwrity backend now includes a comprehensive billing and subscription system that automatically tracks API usage, calculates costs, and manages subscription limits. This system is fully integrated into the startup process and provides real-time monitoring capabilities.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Start the Backend with Billing System
|
||||
|
||||
```bash
|
||||
# From the backend directory
|
||||
python start_alwrity_backend.py
|
||||
```
|
||||
|
||||
The startup script will automatically:
|
||||
- ✅ Create billing and subscription database tables
|
||||
- ✅ Initialize default pricing and subscription plans
|
||||
- ✅ Set up usage tracking middleware
|
||||
- ✅ Verify all billing components are working
|
||||
- ✅ Start the server with billing endpoints enabled
|
||||
|
||||
### 2. Verify Installation
|
||||
|
||||
```bash
|
||||
# Run the comprehensive verification script
|
||||
python verify_billing_setup.py
|
||||
```
|
||||
|
||||
### 3. Test API Endpoints
|
||||
|
||||
```bash
|
||||
# Get subscription plans
|
||||
curl http://localhost:8000/api/subscription/plans
|
||||
|
||||
# Get user usage (replace 'demo' with actual user ID)
|
||||
curl http://localhost:8000/api/subscription/usage/demo
|
||||
|
||||
# Get billing dashboard data
|
||||
curl http://localhost:8000/api/subscription/dashboard/demo
|
||||
|
||||
# Get API pricing information
|
||||
curl http://localhost:8000/api/subscription/pricing
|
||||
```
|
||||
|
||||
## 📊 Database Tables
|
||||
|
||||
The billing system creates the following tables:
|
||||
|
||||
| Table Name | Purpose |
|
||||
|------------|---------|
|
||||
| `subscription_plans` | Available subscription tiers and pricing |
|
||||
| `user_subscriptions` | User subscription assignments |
|
||||
| `api_usage_logs` | Detailed API usage tracking |
|
||||
| `usage_summaries` | Aggregated usage statistics |
|
||||
| `api_provider_pricing` | Cost per token for each AI provider |
|
||||
| `usage_alerts` | Usage limit warnings and notifications |
|
||||
| `billing_history` | Historical billing records |
|
||||
|
||||
## 🔧 System Components
|
||||
|
||||
### 1. Database Models (`models/subscription_models.py`)
|
||||
- **SubscriptionPlan**: Subscription tiers and pricing
|
||||
- **UserSubscription**: User subscription assignments
|
||||
- **APIUsageLog**: Detailed usage tracking
|
||||
- **UsageSummary**: Aggregated statistics
|
||||
- **APIProviderPricing**: Cost calculations
|
||||
- **UsageAlert**: Limit notifications
|
||||
|
||||
### 2. Services
|
||||
- **PricingService** (`services/pricing_service.py`): Cost calculations and plan management
|
||||
- **UsageTrackingService** (`services/usage_tracking_service.py`): Usage monitoring and limits
|
||||
- **SubscriptionExceptionHandler** (`services/subscription_exception_handler.py`): Error handling
|
||||
|
||||
### 3. API Endpoints (`api/subscription_api.py`)
|
||||
- `GET /api/subscription/plans` - Available subscription plans
|
||||
- `GET /api/subscription/usage/{user_id}` - User usage statistics
|
||||
- `GET /api/subscription/dashboard/{user_id}` - Dashboard data
|
||||
- `GET /api/subscription/pricing` - API pricing information
|
||||
- `GET /api/subscription/trends/{user_id}` - Usage trends
|
||||
|
||||
### 4. Middleware Integration
|
||||
- **Monitoring Middleware** (`middleware/monitoring_middleware.py`): Automatic usage tracking
|
||||
- **Exception Handling**: Graceful error handling for billing issues
|
||||
|
||||
## 🎯 Frontend Integration
|
||||
|
||||
The billing system is fully integrated with the frontend dashboard:
|
||||
|
||||
### CompactBillingDashboard
|
||||
- Real-time usage metrics
|
||||
- Cost tracking
|
||||
- System health monitoring
|
||||
- Interactive tooltips and help text
|
||||
|
||||
### EnhancedBillingDashboard
|
||||
- Detailed usage breakdowns
|
||||
- Provider-specific costs
|
||||
- Usage trends and analytics
|
||||
- Alert management
|
||||
|
||||
## 📈 Usage Tracking
|
||||
|
||||
The system automatically tracks:
|
||||
|
||||
- **API Calls**: Number of requests to each provider
|
||||
- **Token Usage**: Input and output tokens for each request
|
||||
- **Costs**: Real-time cost calculations
|
||||
- **Response Times**: Performance monitoring
|
||||
- **Error Rates**: Failed request tracking
|
||||
- **User Activity**: Per-user usage patterns
|
||||
|
||||
## 💰 Pricing Configuration
|
||||
|
||||
### Default AI Provider Pricing (per token)
|
||||
|
||||
| Provider | Model | Input Cost | Output Cost |
|
||||
|----------|-------|------------|-------------|
|
||||
| OpenAI | GPT-4 | $0.00003 | $0.00006 |
|
||||
| OpenAI | GPT-3.5-turbo | $0.0000015 | $0.000002 |
|
||||
| Gemini | Gemini Pro | $0.0000005 | $0.0000015 |
|
||||
| Anthropic | Claude-3 | $0.000008 | $0.000024 |
|
||||
| Mistral | Mistral-7B | $0.0000002 | $0.0000006 |
|
||||
|
||||
### Subscription Plans
|
||||
|
||||
| Plan | Monthly Price | Yearly Price | API Limits |
|
||||
|------|---------------|--------------|------------|
|
||||
| Free | $0 | $0 | 1,000 calls/month |
|
||||
| Starter | $29 | $290 | 10,000 calls/month |
|
||||
| Professional | $99 | $990 | 100,000 calls/month |
|
||||
| Enterprise | $299 | $2,990 | Unlimited |
|
||||
|
||||
## 🔍 Monitoring & Alerts
|
||||
|
||||
### Real-time Monitoring
|
||||
- Usage tracking for all API calls
|
||||
- Cost calculations in real-time
|
||||
- Performance metrics
|
||||
- Error rate monitoring
|
||||
|
||||
### Alert System
|
||||
- Usage approaching limits (80% threshold)
|
||||
- Cost overruns
|
||||
- System health issues
|
||||
- Provider-specific problems
|
||||
|
||||
## 🛠️ Development Mode
|
||||
|
||||
For development with auto-reload:
|
||||
|
||||
```bash
|
||||
# Development mode with auto-reload
|
||||
python start_alwrity_backend.py --dev
|
||||
|
||||
# Or with explicit reload flag
|
||||
python start_alwrity_backend.py --reload
|
||||
```
|
||||
|
||||
## 📝 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The system uses the following environment variables:
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL=sqlite:///./alwrity.db
|
||||
|
||||
# API Keys (configured through onboarding)
|
||||
OPENAI_API_KEY=your_key_here
|
||||
GEMINI_API_KEY=your_key_here
|
||||
ANTHROPIC_API_KEY=your_key_here
|
||||
MISTRAL_API_KEY=your_key_here
|
||||
|
||||
# Server Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=true
|
||||
```
|
||||
|
||||
### Custom Pricing
|
||||
|
||||
To modify pricing, update the `PricingService.initialize_default_pricing()` method in `services/pricing_service.py`.
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Run Verification Script
|
||||
```bash
|
||||
python verify_billing_setup.py
|
||||
```
|
||||
|
||||
### Test Individual Components
|
||||
```bash
|
||||
# Test subscription system
|
||||
python test_subscription_system.py
|
||||
|
||||
# Test billing tables creation
|
||||
python scripts/create_billing_tables.py
|
||||
```
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Tables not created**: Run `python scripts/create_billing_tables.py`
|
||||
2. **Missing dependencies**: Run `pip install -r requirements.txt`
|
||||
3. **Database errors**: Check `DATABASE_URL` in environment
|
||||
4. **API key issues**: Verify API keys are configured
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging by setting `DEBUG=true` in your environment.
|
||||
|
||||
## 📚 API Documentation
|
||||
|
||||
Once the server is running, access the interactive API documentation:
|
||||
|
||||
- **Swagger UI**: http://localhost:8000/api/docs
|
||||
- **ReDoc**: http://localhost:8000/api/redoc
|
||||
|
||||
## 🔄 Updates and Maintenance
|
||||
|
||||
### Adding New Providers
|
||||
|
||||
1. Add provider to `APIProvider` enum in `models/subscription_models.py`
|
||||
2. Update pricing in `PricingService.initialize_default_pricing()`
|
||||
3. Add provider detection in middleware
|
||||
4. Update frontend provider chips
|
||||
|
||||
### Modifying Plans
|
||||
|
||||
1. Update `PricingService.initialize_default_plans()`
|
||||
2. Modify plan limits and pricing
|
||||
3. Test with verification script
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
1. Check the verification script output
|
||||
2. Review the startup logs
|
||||
3. Test individual components
|
||||
4. Check database table creation
|
||||
|
||||
## 🎉 Success Indicators
|
||||
|
||||
You'll know the billing system is working when:
|
||||
|
||||
- ✅ Startup script shows "Billing and subscription tables created successfully"
|
||||
- ✅ Verification script passes all checks
|
||||
- ✅ API endpoints return data
|
||||
- ✅ Frontend dashboard shows usage metrics
|
||||
- ✅ Usage tracking middleware is active
|
||||
|
||||
The billing system is now fully integrated and ready for production use!
|
||||
2
backend/api/blog_writer/__init__.py
Normal file
2
backend/api/blog_writer/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Package init for AI Blog Writer API
|
||||
|
||||
107
backend/api/blog_writer/router.py
Normal file
107
backend/api/blog_writer/router.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Any, Dict
|
||||
|
||||
from models.blog_models import (
|
||||
BlogResearchRequest,
|
||||
BlogResearchResponse,
|
||||
BlogOutlineRequest,
|
||||
BlogOutlineResponse,
|
||||
BlogOutlineRefineRequest,
|
||||
BlogSectionRequest,
|
||||
BlogSectionResponse,
|
||||
BlogOptimizeRequest,
|
||||
BlogOptimizeResponse,
|
||||
BlogSEOAnalyzeRequest,
|
||||
BlogSEOAnalyzeResponse,
|
||||
BlogSEOMetadataRequest,
|
||||
BlogSEOMetadataResponse,
|
||||
BlogPublishRequest,
|
||||
BlogPublishResponse,
|
||||
HallucinationCheckRequest,
|
||||
HallucinationCheckResponse,
|
||||
)
|
||||
from services.blog_writer.blog_service import BlogWriterService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/blog", tags=["AI Blog Writer"])
|
||||
|
||||
service = BlogWriterService()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> Dict[str, Any]:
|
||||
return {"status": "ok", "service": "ai_blog_writer"}
|
||||
|
||||
|
||||
@router.post("/research", response_model=BlogResearchResponse)
|
||||
async def research(request: BlogResearchRequest) -> BlogResearchResponse:
|
||||
try:
|
||||
return await service.research(request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/outline/generate", response_model=BlogOutlineResponse)
|
||||
async def generate_outline(request: BlogOutlineRequest) -> BlogOutlineResponse:
|
||||
try:
|
||||
return await service.generate_outline(request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/outline/refine", response_model=BlogOutlineResponse)
|
||||
async def refine_outline(request: BlogOutlineRefineRequest) -> BlogOutlineResponse:
|
||||
try:
|
||||
return await service.refine_outline(request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/section/generate", response_model=BlogSectionResponse)
|
||||
async def generate_section(request: BlogSectionRequest) -> BlogSectionResponse:
|
||||
try:
|
||||
return await service.generate_section(request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/section/optimize", response_model=BlogOptimizeResponse)
|
||||
async def optimize_section(request: BlogOptimizeRequest) -> BlogOptimizeResponse:
|
||||
try:
|
||||
return await service.optimize_section(request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/quality/hallucination-check", response_model=HallucinationCheckResponse)
|
||||
async def hallucination_check(request: HallucinationCheckRequest) -> HallucinationCheckResponse:
|
||||
try:
|
||||
return await service.hallucination_check(request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/seo/analyze", response_model=BlogSEOAnalyzeResponse)
|
||||
async def seo_analyze(request: BlogSEOAnalyzeRequest) -> BlogSEOAnalyzeResponse:
|
||||
try:
|
||||
return await service.seo_analyze(request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/seo/metadata", response_model=BlogSEOMetadataResponse)
|
||||
async def seo_metadata(request: BlogSEOMetadataRequest) -> BlogSEOMetadataResponse:
|
||||
try:
|
||||
return await service.seo_metadata(request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/publish", response_model=BlogPublishResponse)
|
||||
async def publish(request: BlogPublishRequest) -> BlogPublishResponse:
|
||||
try:
|
||||
return await service.publish(request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@@ -457,6 +457,13 @@ app.include_router(content_planning_router)
|
||||
app.include_router(user_data_router)
|
||||
app.include_router(strategy_copilot_router)
|
||||
|
||||
# Include AI Blog Writer router
|
||||
try:
|
||||
from api.blog_writer.router import router as blog_writer_router
|
||||
app.include_router(blog_writer_router)
|
||||
except Exception as e:
|
||||
logger.warning(f"AI Blog Writer router not mounted: {e}")
|
||||
|
||||
# Include persona router
|
||||
from api.persona_routes import router as persona_router
|
||||
app.include_router(persona_router)
|
||||
|
||||
147
backend/models/blog_models.py
Normal file
147
backend/models/blog_models.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
|
||||
class PersonaInfo(BaseModel):
|
||||
persona_id: Optional[str] = None
|
||||
tone: Optional[str] = None
|
||||
audience: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
|
||||
|
||||
class ResearchSource(BaseModel):
|
||||
title: str
|
||||
url: str
|
||||
excerpt: Optional[str] = None
|
||||
credibility_score: Optional[float] = None
|
||||
published_at: Optional[str] = None
|
||||
|
||||
|
||||
class BlogResearchRequest(BaseModel):
|
||||
keywords: List[str]
|
||||
topic: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
target_audience: Optional[str] = None
|
||||
tone: Optional[str] = None
|
||||
word_count_target: Optional[int] = 1500
|
||||
persona: Optional[PersonaInfo] = None
|
||||
|
||||
|
||||
class BlogResearchResponse(BaseModel):
|
||||
success: bool = True
|
||||
sources: List[ResearchSource] = []
|
||||
keyword_analysis: Dict[str, Any] = {}
|
||||
competitor_analysis: Dict[str, Any] = {}
|
||||
suggested_angles: List[str] = []
|
||||
search_widget: Optional[str] = None # HTML content for search widget
|
||||
search_queries: List[str] = [] # Search queries generated by Gemini
|
||||
|
||||
|
||||
class BlogOutlineSection(BaseModel):
|
||||
id: str
|
||||
heading: str
|
||||
subheadings: List[str] = []
|
||||
key_points: List[str] = []
|
||||
references: List[ResearchSource] = []
|
||||
target_words: Optional[int] = None
|
||||
keywords: List[str] = []
|
||||
|
||||
|
||||
class BlogOutlineRequest(BaseModel):
|
||||
research: BlogResearchResponse
|
||||
persona: Optional[PersonaInfo] = None
|
||||
word_count: Optional[int] = 1500
|
||||
|
||||
|
||||
class BlogOutlineResponse(BaseModel):
|
||||
success: bool = True
|
||||
title_options: List[str] = []
|
||||
outline: List[BlogOutlineSection] = []
|
||||
|
||||
|
||||
class BlogOutlineRefineRequest(BaseModel):
|
||||
outline: List[BlogOutlineSection]
|
||||
operation: str
|
||||
section_id: Optional[str] = None
|
||||
payload: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class BlogSectionRequest(BaseModel):
|
||||
section: BlogOutlineSection
|
||||
keywords: List[str] = []
|
||||
tone: Optional[str] = None
|
||||
persona: Optional[PersonaInfo] = None
|
||||
|
||||
|
||||
class BlogSectionResponse(BaseModel):
|
||||
success: bool = True
|
||||
markdown: str
|
||||
citations: List[ResearchSource] = []
|
||||
|
||||
|
||||
class BlogOptimizeRequest(BaseModel):
|
||||
content: str
|
||||
goals: List[str] = []
|
||||
|
||||
|
||||
class BlogOptimizeResponse(BaseModel):
|
||||
success: bool = True
|
||||
optimized: str
|
||||
diff_preview: Optional[str] = None
|
||||
|
||||
|
||||
class BlogSEOAnalyzeRequest(BaseModel):
|
||||
content: str
|
||||
keywords: List[str] = []
|
||||
|
||||
|
||||
class BlogSEOAnalyzeResponse(BaseModel):
|
||||
success: bool = True
|
||||
seo_score: float
|
||||
density: Dict[str, Any] = {}
|
||||
structure: Dict[str, Any] = {}
|
||||
readability: Dict[str, Any] = {}
|
||||
link_suggestions: List[Dict[str, Any]] = []
|
||||
image_alt_status: Dict[str, Any] = {}
|
||||
recommendations: List[str] = []
|
||||
|
||||
|
||||
class BlogSEOMetadataRequest(BaseModel):
|
||||
content: str
|
||||
title: Optional[str] = None
|
||||
keywords: List[str] = []
|
||||
|
||||
|
||||
class BlogSEOMetadataResponse(BaseModel):
|
||||
success: bool = True
|
||||
title_options: List[str]
|
||||
meta_descriptions: List[str]
|
||||
open_graph: Dict[str, Any]
|
||||
twitter_card: Dict[str, Any]
|
||||
schema_data: Dict[str, Any]
|
||||
|
||||
|
||||
class BlogPublishRequest(BaseModel):
|
||||
platform: str = Field(pattern="^(wix|wordpress)$")
|
||||
html: str
|
||||
metadata: BlogSEOMetadataResponse
|
||||
schedule_time: Optional[str] = None
|
||||
|
||||
|
||||
class BlogPublishResponse(BaseModel):
|
||||
success: bool = True
|
||||
platform: str
|
||||
url: Optional[str] = None
|
||||
post_id: Optional[str] = None
|
||||
|
||||
|
||||
class HallucinationCheckRequest(BaseModel):
|
||||
content: str
|
||||
sources: List[str] = []
|
||||
|
||||
|
||||
class HallucinationCheckResponse(BaseModel):
|
||||
success: bool = True
|
||||
claims: List[Dict[str, Any]] = []
|
||||
suggestions: List[Dict[str, Any]] = []
|
||||
|
||||
84
backend/research_analysis_20250911_173832.json
Normal file
84
backend/research_analysis_20250911_173832.json
Normal file
File diff suppressed because one or more lines are too long
206
backend/research_analysis_20250911_174238.json
Normal file
206
backend/research_analysis_20250911_174238.json
Normal file
File diff suppressed because one or more lines are too long
217
backend/scripts/create_billing_tables.py
Normal file
217
backend/scripts/create_billing_tables.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Database Migration Script for Billing System
|
||||
Creates all tables needed for billing, usage tracking, and subscription management.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add the backend directory to Python path
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from loguru import logger
|
||||
import traceback
|
||||
|
||||
# Import models
|
||||
from models.subscription_models import Base as SubscriptionBase
|
||||
from services.database import DATABASE_URL
|
||||
from services.pricing_service import PricingService
|
||||
|
||||
def create_billing_tables():
|
||||
"""Create all billing and subscription-related tables."""
|
||||
|
||||
try:
|
||||
# Create engine
|
||||
engine = create_engine(DATABASE_URL, echo=True)
|
||||
|
||||
# Create all tables
|
||||
logger.info("Creating billing and subscription system tables...")
|
||||
SubscriptionBase.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Billing and subscription tables created successfully")
|
||||
|
||||
# Create session for data initialization
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Initialize pricing and plans
|
||||
pricing_service = PricingService(db)
|
||||
|
||||
logger.info("Initializing default API pricing...")
|
||||
pricing_service.initialize_default_pricing()
|
||||
logger.info("✅ Default API pricing initialized")
|
||||
|
||||
logger.info("Initializing default subscription plans...")
|
||||
pricing_service.initialize_default_plans()
|
||||
logger.info("✅ Default subscription plans initialized")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing default data: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info("🎉 Billing system setup completed successfully!")
|
||||
|
||||
# Display summary
|
||||
display_setup_summary(engine)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error creating billing tables: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def display_setup_summary(engine):
|
||||
"""Display a summary of the created tables and data."""
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
logger.info("\n" + "="*60)
|
||||
logger.info("BILLING SYSTEM SETUP SUMMARY")
|
||||
logger.info("="*60)
|
||||
|
||||
# Check tables
|
||||
tables_query = text("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND (
|
||||
name LIKE '%subscription%' OR
|
||||
name LIKE '%usage%' OR
|
||||
name LIKE '%billing%' OR
|
||||
name LIKE '%pricing%' OR
|
||||
name LIKE '%alert%'
|
||||
)
|
||||
ORDER BY name
|
||||
""")
|
||||
|
||||
result = conn.execute(tables_query)
|
||||
tables = result.fetchall()
|
||||
|
||||
logger.info(f"\n📊 Created Tables ({len(tables)}):")
|
||||
for table in tables:
|
||||
logger.info(f" • {table[0]}")
|
||||
|
||||
# Check subscription plans
|
||||
try:
|
||||
plans_query = text("SELECT COUNT(*) FROM subscription_plans")
|
||||
result = conn.execute(plans_query)
|
||||
plan_count = result.fetchone()[0]
|
||||
logger.info(f"\n💳 Subscription Plans: {plan_count}")
|
||||
|
||||
if plan_count > 0:
|
||||
plans_detail_query = text("""
|
||||
SELECT name, tier, price_monthly, price_yearly
|
||||
FROM subscription_plans
|
||||
ORDER BY price_monthly
|
||||
""")
|
||||
result = conn.execute(plans_detail_query)
|
||||
plans = result.fetchall()
|
||||
|
||||
for plan in plans:
|
||||
name, tier, monthly, yearly = plan
|
||||
logger.info(f" • {name} ({tier}): ${monthly}/month, ${yearly}/year")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not check subscription plans: {e}")
|
||||
|
||||
# Check API pricing
|
||||
try:
|
||||
pricing_query = text("SELECT COUNT(*) FROM api_provider_pricing")
|
||||
result = conn.execute(pricing_query)
|
||||
pricing_count = result.fetchone()[0]
|
||||
logger.info(f"\n💰 API Pricing Entries: {pricing_count}")
|
||||
|
||||
if pricing_count > 0:
|
||||
pricing_detail_query = text("""
|
||||
SELECT provider, model_name, cost_per_input_token, cost_per_output_token
|
||||
FROM api_provider_pricing
|
||||
WHERE cost_per_input_token > 0 OR cost_per_output_token > 0
|
||||
ORDER BY provider, model_name
|
||||
LIMIT 10
|
||||
""")
|
||||
result = conn.execute(pricing_detail_query)
|
||||
pricing_entries = result.fetchall()
|
||||
|
||||
logger.info("\n LLM Pricing (per token) - Top 10:")
|
||||
for entry in pricing_entries:
|
||||
provider, model, input_cost, output_cost = entry
|
||||
logger.info(f" • {provider}/{model}: ${input_cost:.8f} in, ${output_cost:.8f} out")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not check API pricing: {e}")
|
||||
|
||||
logger.info("\n" + "="*60)
|
||||
logger.info("NEXT STEPS:")
|
||||
logger.info("="*60)
|
||||
logger.info("1. Billing system is ready for use")
|
||||
logger.info("2. API endpoints are available at:")
|
||||
logger.info(" GET /api/subscription/plans")
|
||||
logger.info(" GET /api/subscription/usage/{user_id}")
|
||||
logger.info(" GET /api/subscription/dashboard/{user_id}")
|
||||
logger.info(" GET /api/subscription/pricing")
|
||||
logger.info("\n3. Frontend billing dashboard is integrated")
|
||||
logger.info("4. Usage tracking middleware is active")
|
||||
logger.info("5. Real-time cost monitoring is enabled")
|
||||
logger.info("="*60)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error displaying summary: {e}")
|
||||
|
||||
def check_existing_tables(engine):
|
||||
"""Check if billing tables already exist."""
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
# Check for billing tables
|
||||
check_query = text("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND (
|
||||
name = 'subscription_plans' OR
|
||||
name = 'user_subscriptions' OR
|
||||
name = 'api_usage_logs' OR
|
||||
name = 'usage_summaries' OR
|
||||
name = 'api_provider_pricing' OR
|
||||
name = 'usage_alerts'
|
||||
)
|
||||
""")
|
||||
|
||||
result = conn.execute(check_query)
|
||||
existing_tables = result.fetchall()
|
||||
|
||||
if existing_tables:
|
||||
logger.warning(f"Found existing billing tables: {[t[0] for t in existing_tables]}")
|
||||
logger.info("Tables already exist. Skipping creation to preserve data.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking existing tables: {e}")
|
||||
return True # Proceed anyway
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("🚀 Starting billing system database migration...")
|
||||
|
||||
try:
|
||||
# Create engine to check existing tables
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
|
||||
# Check existing tables
|
||||
if not check_existing_tables(engine):
|
||||
logger.info("✅ Billing tables already exist, skipping creation")
|
||||
sys.exit(0)
|
||||
|
||||
# Create tables and initialize data
|
||||
create_billing_tables()
|
||||
|
||||
logger.info("✅ Billing system migration completed successfully!")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Migration cancelled by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Migration failed: {e}")
|
||||
sys.exit(1)
|
||||
649
backend/services/blog_writer/blog_service.py
Normal file
649
backend/services/blog_writer/blog_service.py
Normal file
@@ -0,0 +1,649 @@
|
||||
from typing import Any, Dict, List
|
||||
from loguru import logger
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
|
||||
from models.blog_models import (
|
||||
BlogResearchRequest,
|
||||
BlogResearchResponse,
|
||||
BlogOutlineRequest,
|
||||
BlogOutlineResponse,
|
||||
BlogOutlineRefineRequest,
|
||||
BlogSectionRequest,
|
||||
BlogSectionResponse,
|
||||
BlogOptimizeRequest,
|
||||
BlogOptimizeResponse,
|
||||
BlogSEOAnalyzeRequest,
|
||||
BlogSEOAnalyzeResponse,
|
||||
BlogSEOMetadataRequest,
|
||||
BlogSEOMetadataResponse,
|
||||
BlogPublishRequest,
|
||||
BlogPublishResponse,
|
||||
ResearchSource,
|
||||
BlogOutlineSection,
|
||||
)
|
||||
|
||||
|
||||
class BlogWriterService:
|
||||
"""Service layer for AI Blog Writer (stub implementations for scaffolding)."""
|
||||
|
||||
async def research(self, request: BlogResearchRequest) -> BlogResearchResponse:
|
||||
"""
|
||||
Stage 1: Research & Strategy (AI Orchestration)
|
||||
Uses ONLY Gemini's native Google Search grounding - ONE API call for everything.
|
||||
Follows LinkedIn service pattern for efficiency and cost optimization.
|
||||
"""
|
||||
from services.llm_providers.gemini_grounded_provider import GeminiGroundedProvider
|
||||
|
||||
gemini = GeminiGroundedProvider()
|
||||
|
||||
topic = request.topic or ", ".join(request.keywords)
|
||||
industry = request.industry or (request.persona.industry if request.persona and request.persona.industry else "General")
|
||||
target_audience = getattr(request.persona, 'target_audience', 'General') if request.persona else 'General'
|
||||
|
||||
# Single comprehensive research prompt - Gemini handles Google Search automatically
|
||||
research_prompt = f"""
|
||||
Research the topic "{topic}" in the {industry} industry for {target_audience} audience. Provide a comprehensive analysis including:
|
||||
|
||||
1. Current trends and insights (2024-2025)
|
||||
2. Key statistics and data points with sources
|
||||
3. Industry expert opinions and quotes
|
||||
4. Recent developments and news
|
||||
5. Market analysis and forecasts
|
||||
6. Best practices and case studies
|
||||
7. Keyword analysis: primary, secondary, and long-tail opportunities
|
||||
8. Competitor analysis: top players and content gaps
|
||||
9. Content angle suggestions: 5 compelling angles for blog posts
|
||||
|
||||
Focus on factual, up-to-date information from credible sources.
|
||||
Include specific data points, percentages, and recent developments.
|
||||
Structure your response with clear sections for each analysis area.
|
||||
"""
|
||||
|
||||
# Single Gemini call with native Google Search grounding - no fallbacks
|
||||
gemini_result = await gemini.generate_grounded_content(
|
||||
prompt=research_prompt,
|
||||
content_type="research",
|
||||
max_tokens=2000
|
||||
)
|
||||
|
||||
# Extract sources from grounding metadata
|
||||
sources = self._extract_sources_from_grounding(gemini_result)
|
||||
|
||||
# Extract search widget and queries for UI display
|
||||
search_widget = gemini_result.get("search_widget", "") or ""
|
||||
search_queries = gemini_result.get("search_queries", []) or []
|
||||
|
||||
# Parse the comprehensive response for different analysis components
|
||||
content = gemini_result.get("content", "")
|
||||
keyword_analysis = self._parse_keyword_analysis(content, request.keywords)
|
||||
competitor_analysis = self._parse_competitor_analysis(content)
|
||||
suggested_angles = self._parse_content_angles(content, topic, industry)
|
||||
|
||||
logger.info(f"Research completed successfully with {len(sources)} sources and {len(search_queries)} search queries")
|
||||
|
||||
return BlogResearchResponse(
|
||||
success=True,
|
||||
sources=sources,
|
||||
keyword_analysis=keyword_analysis,
|
||||
competitor_analysis=competitor_analysis,
|
||||
suggested_angles=suggested_angles,
|
||||
# Add search widget and queries for UI display
|
||||
search_widget=search_widget if 'search_widget' in locals() else "",
|
||||
search_queries=search_queries if 'search_queries' in locals() else [],
|
||||
)
|
||||
|
||||
def _extract_sources_from_grounding(self, gemini_result: Dict[str, Any]) -> List[ResearchSource]:
|
||||
"""Extract sources from Gemini grounding metadata."""
|
||||
sources = []
|
||||
|
||||
# The Gemini grounded provider already extracts sources and puts them in the 'sources' field
|
||||
raw_sources = gemini_result.get("sources", [])
|
||||
for src in raw_sources:
|
||||
source = ResearchSource(
|
||||
title=src.get("title", "Untitled"),
|
||||
url=src.get("url", ""),
|
||||
excerpt=src.get("content", "")[:500] if src.get("content") else f"Source from {src.get('title', 'web')}",
|
||||
credibility_score=float(src.get("credibility_score", 0.8)),
|
||||
published_at=str(src.get("publication_date", "2024-01-01"))
|
||||
)
|
||||
sources.append(source)
|
||||
|
||||
return sources
|
||||
|
||||
def _parse_keyword_analysis(self, content: str, original_keywords: List[str]) -> Dict[str, Any]:
|
||||
"""Parse keyword analysis from the research content."""
|
||||
# Extract keywords from content sections
|
||||
lines = content.split('\n')
|
||||
keyword_section = []
|
||||
in_keyword_section = False
|
||||
|
||||
for line in lines:
|
||||
if 'keyword' in line.lower() and ('analysis' in line.lower() or 'primary' in line.lower()):
|
||||
in_keyword_section = True
|
||||
continue
|
||||
if in_keyword_section and line.strip():
|
||||
if line.startswith(('1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.')):
|
||||
break
|
||||
keyword_section.append(line.strip())
|
||||
|
||||
return {
|
||||
"primary": original_keywords[:1] if original_keywords else [],
|
||||
"secondary": original_keywords[1:] if len(original_keywords) > 1 else [],
|
||||
"long_tail": [f"{kw} guide" for kw in original_keywords[:2]] if original_keywords else [],
|
||||
"search_intent": "informational",
|
||||
"difficulty": 6,
|
||||
"content_gaps": [f"{kw} best practices" for kw in original_keywords[:2]] if original_keywords else [],
|
||||
"analysis_content": "\n".join(keyword_section) if keyword_section else content[:200]
|
||||
}
|
||||
|
||||
def _parse_competitor_analysis(self, content: str) -> Dict[str, Any]:
|
||||
"""Parse competitor analysis from the research content."""
|
||||
lines = content.split('\n')
|
||||
competitor_section = []
|
||||
in_competitor_section = False
|
||||
|
||||
for line in lines:
|
||||
if 'competitor' in line.lower() and ('analysis' in line.lower() or 'top' in line.lower()):
|
||||
in_competitor_section = True
|
||||
continue
|
||||
if in_competitor_section and line.strip():
|
||||
if line.startswith(('1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.')):
|
||||
break
|
||||
competitor_section.append(line.strip())
|
||||
|
||||
return {
|
||||
"top_competitors": [],
|
||||
"content_gaps": [],
|
||||
"opportunities": [],
|
||||
"analysis_notes": "\n".join(competitor_section) if competitor_section else "Competitor analysis from research"
|
||||
}
|
||||
|
||||
def _parse_content_angles(self, content: str, topic: str, industry: str) -> List[str]:
|
||||
"""Parse content angles from the research content."""
|
||||
lines = content.split('\n')
|
||||
angles_section = []
|
||||
in_angles_section = False
|
||||
|
||||
for line in lines:
|
||||
if 'angle' in line.lower() and ('suggest' in line.lower() or 'content' in line.lower()):
|
||||
in_angles_section = True
|
||||
continue
|
||||
if in_angles_section and line.strip():
|
||||
if line.startswith(('1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.')):
|
||||
break
|
||||
if line.strip() and not line.startswith(('•', '-', '*')):
|
||||
angles_section.append(line.strip())
|
||||
|
||||
# If no angles found in content, use fallback
|
||||
if not angles_section:
|
||||
angles_section = [
|
||||
f"How {topic} is Transforming {industry}",
|
||||
f"Latest {topic} Trends: What You Need to Know",
|
||||
f"{topic} Best Practices for {industry}",
|
||||
f"Case Study: {topic} Success Stories",
|
||||
f"The Future of {topic} in {industry}"
|
||||
]
|
||||
|
||||
return angles_section[:5] # Return top 5 angles
|
||||
|
||||
|
||||
async def generate_outline(self, request: BlogOutlineRequest) -> BlogOutlineResponse:
|
||||
"""
|
||||
Stage 2: Content Planning with AI-generated outline using research results
|
||||
Uses Gemini with research data to create comprehensive, SEO-optimized outline
|
||||
"""
|
||||
# Extract research insights
|
||||
research = request.research
|
||||
primary_keywords = research.keyword_analysis.get('primary', [])
|
||||
secondary_keywords = research.keyword_analysis.get('secondary', [])
|
||||
content_angles = research.suggested_angles
|
||||
sources = research.sources
|
||||
search_intent = research.keyword_analysis.get('search_intent', 'informational')
|
||||
|
||||
# Build sophisticated outline generation prompt with advanced content strategy
|
||||
outline_prompt = f"""
|
||||
You are a world-class content strategist and SEO expert with 15+ years of experience creating viral, high-converting blog content. Your outlines have generated millions of views and driven significant business results.
|
||||
|
||||
CONTENT STRATEGY BRIEF:
|
||||
Topic: {', '.join(primary_keywords)}
|
||||
Search Intent: {search_intent}
|
||||
Target Word Count: {request.word_count or 1500} words
|
||||
Industry Context: {getattr(request.persona, 'industry', 'General') if request.persona else 'General'}
|
||||
Audience: {getattr(request.persona, 'target_audience', 'General') if request.persona else 'General'}
|
||||
|
||||
RESEARCH INTELLIGENCE:
|
||||
Primary Keywords: {', '.join(primary_keywords)}
|
||||
Secondary Keywords: {', '.join(secondary_keywords)}
|
||||
Long-tail Opportunities: {', '.join(research.keyword_analysis.get('long_tail', [])[:5])}
|
||||
|
||||
Content Angles Discovered:
|
||||
{chr(10).join([f"• {angle}" for angle in content_angles[:6]])}
|
||||
|
||||
Research Sources Available: {len(sources)} authoritative sources with current data
|
||||
|
||||
STRATEGIC OUTLINE REQUIREMENTS:
|
||||
|
||||
1. CONTENT ARCHITECTURE:
|
||||
- Create 5-7 sections that follow a logical progression
|
||||
- Each section must have a clear purpose and value proposition
|
||||
- Build a narrative arc that keeps readers engaged throughout
|
||||
- Include strategic content gaps that competitors miss
|
||||
|
||||
2. SEO OPTIMIZATION:
|
||||
- Naturally integrate primary keywords in H2 headings (not forced)
|
||||
- Use secondary keywords in subheadings and key points
|
||||
- Include long-tail keywords in natural language
|
||||
- Optimize for featured snippets and voice search
|
||||
- Create semantic keyword clusters
|
||||
|
||||
3. READER ENGAGEMENT:
|
||||
- Start with a compelling hook that addresses pain points
|
||||
- Use storytelling elements and real-world examples
|
||||
- Include actionable insights readers can implement immediately
|
||||
- Create sections that encourage social sharing
|
||||
- End with a strong call-to-action
|
||||
|
||||
4. CONTENT DEPTH:
|
||||
- Each section: 2-4 specific, actionable subheadings
|
||||
- Each section: 4-6 key points with research-backed insights
|
||||
- Include data points, statistics, and case studies where relevant
|
||||
- Address common objections and questions
|
||||
- Provide unique angles not covered by competitors
|
||||
|
||||
5. WORD COUNT DISTRIBUTION:
|
||||
- Introduction: 10-15% of total words
|
||||
- Main sections: 70-80% of total words (distributed strategically)
|
||||
- Conclusion: 10-15% of total words
|
||||
- Total target: {request.word_count or 1500} words
|
||||
|
||||
6. COMPETITIVE ADVANTAGE:
|
||||
- Include fresh perspectives from recent research
|
||||
- Address emerging trends and future implications
|
||||
- Provide deeper insights than surface-level content
|
||||
- Include practical tools, frameworks, or templates
|
||||
- Reference authoritative sources and data
|
||||
|
||||
TITLE STRATEGY:
|
||||
Create 3 distinct title options that:
|
||||
- Include primary keywords naturally
|
||||
- Promise clear value to readers
|
||||
- Create curiosity and urgency
|
||||
- Are optimized for click-through rates
|
||||
- Work well for social media sharing
|
||||
|
||||
CRITICAL: Respond ONLY with valid JSON. No additional text or explanations.
|
||||
|
||||
JSON FORMAT:
|
||||
{{
|
||||
"title_options": [
|
||||
"Compelling title with primary keyword and benefit",
|
||||
"Question-based title that creates curiosity",
|
||||
"How-to title with specific outcome promise"
|
||||
],
|
||||
"outline": [
|
||||
{{
|
||||
"heading": "Strategic section title with primary keyword",
|
||||
"subheadings": [
|
||||
"Specific, actionable subheading 1",
|
||||
"Data-driven subheading 2",
|
||||
"Case study or example subheading 3"
|
||||
],
|
||||
"key_points": [
|
||||
"Research-backed insight with specific data",
|
||||
"Actionable step readers can take immediately",
|
||||
"Common mistake to avoid with explanation",
|
||||
"Advanced tip that provides competitive advantage",
|
||||
"Real-world example or case study"
|
||||
],
|
||||
"target_words": 300,
|
||||
"keywords": ["primary keyword", "secondary keyword", "long-tail phrase"]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
logger.info("Generating AI-powered outline using research results")
|
||||
|
||||
# Define the schema for structured JSON response
|
||||
outline_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title_options": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "3 SEO-optimized title options"
|
||||
},
|
||||
"outline": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"heading": {"type": "string"},
|
||||
"subheadings": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
},
|
||||
"key_points": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
},
|
||||
"word_count": {"type": "integer"},
|
||||
"keywords": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Keywords to focus on in this section"
|
||||
}
|
||||
},
|
||||
"required": ["id", "heading", "subheadings", "key_points", "word_count", "keywords"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["title_options", "outline"]
|
||||
}
|
||||
|
||||
# Generate outline using structured JSON response (no grounding needed)
|
||||
outline_data = gemini_structured_json_response(
|
||||
prompt=outline_prompt,
|
||||
schema=outline_schema,
|
||||
temperature=0.3,
|
||||
max_tokens=3000
|
||||
)
|
||||
|
||||
# Check for errors in the response
|
||||
if isinstance(outline_data, dict) and 'error' in outline_data:
|
||||
logger.error(f"Gemini structured response error: {outline_data['error']}")
|
||||
raise ValueError(f"AI outline generation failed: {outline_data['error']}")
|
||||
|
||||
# Validate required fields
|
||||
if not isinstance(outline_data, dict) or 'outline' not in outline_data or not isinstance(outline_data['outline'], list):
|
||||
logger.error(f"Invalid outline structure: {outline_data}")
|
||||
raise ValueError("Invalid outline structure in Gemini response")
|
||||
|
||||
# Convert to BlogOutlineSection objects
|
||||
outline_sections = []
|
||||
for i, section_data in enumerate(outline_data.get('outline', [])):
|
||||
if not isinstance(section_data, dict) or 'heading' not in section_data:
|
||||
logger.warning(f"Skipping invalid section data at index {i}")
|
||||
continue
|
||||
|
||||
section = BlogOutlineSection(
|
||||
id=f"s{i+1}",
|
||||
heading=section_data.get('heading', f'Section {i+1}'),
|
||||
subheadings=section_data.get('subheadings', []),
|
||||
key_points=section_data.get('key_points', []),
|
||||
references=sources[:2] if i < 2 else [], # Assign sources to first 2 sections
|
||||
target_words=section_data.get('target_words', 300),
|
||||
keywords=section_data.get('keywords', [])
|
||||
)
|
||||
outline_sections.append(section)
|
||||
|
||||
title_options = outline_data.get('title_options', [])
|
||||
if not title_options:
|
||||
raise ValueError("No title options provided in Gemini response")
|
||||
|
||||
logger.info(f"Generated outline with {len(outline_sections)} sections and {len(title_options)} title options")
|
||||
|
||||
return BlogOutlineResponse(
|
||||
success=True,
|
||||
title_options=title_options,
|
||||
outline=outline_sections
|
||||
)
|
||||
|
||||
|
||||
async def refine_outline(self, request: BlogOutlineRefineRequest) -> BlogOutlineResponse:
|
||||
"""
|
||||
Refine outline with HITL (Human-in-the-Loop) operations
|
||||
Supports add, remove, move, merge, rename operations
|
||||
"""
|
||||
outline = request.outline.copy()
|
||||
operation = request.operation.lower()
|
||||
section_id = request.section_id
|
||||
payload = request.payload or {}
|
||||
|
||||
try:
|
||||
if operation == 'add':
|
||||
# Add new section
|
||||
new_section = BlogOutlineSection(
|
||||
id=f"s{len(outline) + 1}",
|
||||
heading=payload.get('heading', 'New Section'),
|
||||
subheadings=payload.get('subheadings', []),
|
||||
key_points=payload.get('key_points', []),
|
||||
references=[],
|
||||
target_words=payload.get('target_words', 300)
|
||||
)
|
||||
outline.append(new_section)
|
||||
logger.info(f"Added new section: {new_section.heading}")
|
||||
|
||||
elif operation == 'remove' and section_id:
|
||||
# Remove section
|
||||
outline = [s for s in outline if s.id != section_id]
|
||||
logger.info(f"Removed section: {section_id}")
|
||||
|
||||
elif operation == 'rename' and section_id:
|
||||
# Rename section
|
||||
for section in outline:
|
||||
if section.id == section_id:
|
||||
section.heading = payload.get('heading', section.heading)
|
||||
break
|
||||
logger.info(f"Renamed section {section_id} to: {payload.get('heading')}")
|
||||
|
||||
elif operation == 'move' and section_id:
|
||||
# Move section (reorder)
|
||||
direction = payload.get('direction', 'down') # 'up' or 'down'
|
||||
current_index = next((i for i, s in enumerate(outline) if s.id == section_id), -1)
|
||||
|
||||
if current_index != -1:
|
||||
if direction == 'up' and current_index > 0:
|
||||
outline[current_index], outline[current_index - 1] = outline[current_index - 1], outline[current_index]
|
||||
elif direction == 'down' and current_index < len(outline) - 1:
|
||||
outline[current_index], outline[current_index + 1] = outline[current_index + 1], outline[current_index]
|
||||
logger.info(f"Moved section {section_id} {direction}")
|
||||
|
||||
elif operation == 'merge' and section_id:
|
||||
# Merge with next section
|
||||
current_index = next((i for i, s in enumerate(outline) if s.id == section_id), -1)
|
||||
if current_index != -1 and current_index < len(outline) - 1:
|
||||
current_section = outline[current_index]
|
||||
next_section = outline[current_index + 1]
|
||||
|
||||
# Merge sections
|
||||
current_section.heading = f"{current_section.heading} & {next_section.heading}"
|
||||
current_section.subheadings.extend(next_section.subheadings)
|
||||
current_section.key_points.extend(next_section.key_points)
|
||||
current_section.references.extend(next_section.references)
|
||||
current_section.target_words = (current_section.target_words or 0) + (next_section.target_words or 0)
|
||||
|
||||
# Remove the next section
|
||||
outline.pop(current_index + 1)
|
||||
logger.info(f"Merged section {section_id} with next section")
|
||||
|
||||
elif operation == 'update' and section_id:
|
||||
# Update section details
|
||||
for section in outline:
|
||||
if section.id == section_id:
|
||||
if 'heading' in payload:
|
||||
section.heading = payload['heading']
|
||||
if 'subheadings' in payload:
|
||||
section.subheadings = payload['subheadings']
|
||||
if 'key_points' in payload:
|
||||
section.key_points = payload['key_points']
|
||||
if 'target_words' in payload:
|
||||
section.target_words = payload['target_words']
|
||||
break
|
||||
logger.info(f"Updated section {section_id}")
|
||||
|
||||
# Reassign IDs to maintain order
|
||||
for i, section in enumerate(outline):
|
||||
section.id = f"s{i+1}"
|
||||
|
||||
return BlogOutlineResponse(
|
||||
success=True,
|
||||
title_options=["Refined Outline"],
|
||||
outline=outline
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Outline refinement failed: {e}")
|
||||
return BlogOutlineResponse(
|
||||
success=False,
|
||||
title_options=["Error"],
|
||||
outline=request.outline
|
||||
)
|
||||
|
||||
async def generate_section(self, request: BlogSectionRequest) -> BlogSectionResponse:
|
||||
# TODO: Generate section markdown incorporating references and persona/tone
|
||||
md = f"## {request.section.heading}\n\nThis section content will be generated here.\n"
|
||||
return BlogSectionResponse(success=True, markdown=md, citations=request.section.references)
|
||||
|
||||
async def optimize_section(self, request: BlogOptimizeRequest) -> BlogOptimizeResponse:
|
||||
# TODO: Run readability/EEAT optimization and return diff
|
||||
return BlogOptimizeResponse(success=True, optimized=request.content, diff_preview=None)
|
||||
|
||||
async def hallucination_check(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run hallucination detection on provided text using existing detector service."""
|
||||
text = str(payload.get("text", "") or "").strip()
|
||||
if not text:
|
||||
return {"success": False, "error": "No text provided"}
|
||||
|
||||
# Prefer direct service use over HTTP proxy
|
||||
try:
|
||||
from services.hallucination_detector import HallucinationDetector
|
||||
detector = HallucinationDetector()
|
||||
result = await detector.detect_hallucinations(text)
|
||||
|
||||
# Serialize dataclass-like result to dict
|
||||
claims = []
|
||||
for c in result.claims:
|
||||
claims.append({
|
||||
"text": c.text,
|
||||
"confidence": c.confidence,
|
||||
"assessment": c.assessment,
|
||||
"supporting_sources": c.supporting_sources,
|
||||
"refuting_sources": c.refuting_sources,
|
||||
"reasoning": c.reasoning,
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"overall_confidence": result.overall_confidence,
|
||||
"total_claims": result.total_claims,
|
||||
"supported_claims": result.supported_claims,
|
||||
"refuted_claims": result.refuted_claims,
|
||||
"insufficient_claims": result.insufficient_claims,
|
||||
"timestamp": result.timestamp,
|
||||
"claims": claims,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def seo_analyze(self, request: BlogSEOAnalyzeRequest) -> BlogSEOAnalyzeResponse:
|
||||
"""Wrap existing SEO tools to produce unified analysis for blog content."""
|
||||
from services.seo_tools.on_page_seo_service import OnPageSEOService
|
||||
from services.seo_tools.image_alt_service import ImageAltService
|
||||
from services.seo_tools.content_strategy_service import ContentStrategyService
|
||||
|
||||
content = request.content or ""
|
||||
target_keywords = request.keywords or []
|
||||
|
||||
# On-page analysis (treat content as a virtual URL/document for now)
|
||||
on_page = OnPageSEOService()
|
||||
on_page_result = await on_page.analyze_on_page_seo(url="about:blank", target_keywords=target_keywords)
|
||||
|
||||
# Image alt coverage (placeholder: no images in raw content yet)
|
||||
try:
|
||||
image_alt_service = ImageAltService()
|
||||
image_alt_status = {"total_images": 0, "missing_alt": 0}
|
||||
except Exception:
|
||||
image_alt_status = {"total_images": 0, "missing_alt": 0}
|
||||
|
||||
# Strategy hints (keywords/topics)
|
||||
try:
|
||||
strategy = ContentStrategyService()
|
||||
strategy_hints = await strategy.analyze_content_topics(content=content)
|
||||
except Exception:
|
||||
strategy_hints = {"topics": [], "gaps": []}
|
||||
|
||||
# Lightweight markdown parsing for headings/links/keywords
|
||||
import re
|
||||
content_text = content or ""
|
||||
words = re.findall(r"[A-Za-z0-9']+", content_text)
|
||||
total_words = max(len(words), 1)
|
||||
heading_lines = content_text.splitlines()
|
||||
h1 = sum(1 for ln in heading_lines if ln.startswith('# '))
|
||||
h2 = sum(1 for ln in heading_lines if ln.startswith('## '))
|
||||
h3 = sum(1 for ln in heading_lines if ln.startswith('### '))
|
||||
md_links = re.findall(r"\[([^\]]+)\]\(([^)]+)\)", content_text)
|
||||
external_links = [u for (_t, u) in md_links if u.startswith('http')]
|
||||
|
||||
# Keyword density
|
||||
density_map: Dict[str, Any] = {"target_keywords": target_keywords}
|
||||
for kw in target_keywords:
|
||||
try:
|
||||
occurrences = len(re.findall(re.escape(kw), content_text, flags=re.IGNORECASE))
|
||||
except re.error:
|
||||
occurrences = 0
|
||||
density_map[kw] = {
|
||||
"occurrences": occurrences,
|
||||
"density": round(occurrences / total_words, 4)
|
||||
}
|
||||
|
||||
# Build unified response
|
||||
recommendations: List[str] = []
|
||||
if isinstance(on_page_result.get("recommendations"), list):
|
||||
recommendations.extend(on_page_result["recommendations"])
|
||||
if strategy_hints.get("gaps"):
|
||||
recommendations.append("Cover missing topics: " + ", ".join(strategy_hints["gaps"]))
|
||||
if not external_links:
|
||||
recommendations.append("Add at least one credible external link to authoritative sources.")
|
||||
if h2 < 2:
|
||||
recommendations.append("Increase number of H2 sections for better structure.")
|
||||
|
||||
# Internal link suggestions: generate anchors for H2s and propose cross-links
|
||||
def to_anchor(h: str) -> str:
|
||||
import re
|
||||
a = re.sub(r"[^a-z0-9\s-]", "", h.lower())
|
||||
a = re.sub(r"\s+", "-", a).strip('-')
|
||||
return a
|
||||
h2_headings = [ln[3:].strip() for ln in heading_lines if ln.startswith('## ')]
|
||||
anchors = [to_anchor(h) for h in h2_headings]
|
||||
internal_link_suggestions = []
|
||||
for i in range(len(anchors)-1):
|
||||
internal_link_suggestions.append({
|
||||
"from": h2_headings[i],
|
||||
"to": h2_headings[i+1],
|
||||
"anchor": f"#{anchors[i+1]}",
|
||||
"suggestion": f"Add internal link from '{h2_headings[i]}' to '{h2_headings[i+1]}'"
|
||||
})
|
||||
|
||||
return BlogSEOAnalyzeResponse(
|
||||
success=True,
|
||||
seo_score=float(on_page_result.get("overall_score", 75)),
|
||||
density=density_map,
|
||||
structure={
|
||||
**on_page_result.get("heading_structure", {}),
|
||||
"markdown_headings": {"h1": h1, "h2": h2, "h3": h3},
|
||||
"links": {"total": len(md_links), "external": len(external_links)}
|
||||
},
|
||||
readability=on_page_result.get("content_analysis", {}),
|
||||
link_suggestions=([{"suggestion": "Add external citation links for key claims."}] if not external_links else []) + internal_link_suggestions,
|
||||
image_alt_status=image_alt_status,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
|
||||
async def seo_metadata(self, request: BlogSEOMetadataRequest) -> BlogSEOMetadataResponse:
|
||||
# TODO: Generate SEO metadata using existing services
|
||||
return BlogSEOMetadataResponse(
|
||||
success=True,
|
||||
title_options=[request.title or "Generated SEO Title"],
|
||||
meta_descriptions=["Compelling meta description..."],
|
||||
open_graph={"title": request.title or "OG Title", "image": ""},
|
||||
twitter_card={"card": "summary_large_image"},
|
||||
schema={"@type": "Article"},
|
||||
)
|
||||
|
||||
async def publish(self, request: BlogPublishRequest) -> BlogPublishResponse:
|
||||
# TODO: Call Wix/WordPress adapters to publish
|
||||
return BlogPublishResponse(success=True, platform=request.platform, url="https://example.com/post")
|
||||
|
||||
|
||||
Binary file not shown.
@@ -43,7 +43,7 @@ class GeminiGroundedProvider:
|
||||
|
||||
# Initialize the Gemini client with timeout configuration
|
||||
self.client = genai.Client(api_key=self.api_key)
|
||||
self.timeout = 30 # 30 second timeout for API calls
|
||||
self.timeout = 60 # 60 second timeout for API calls (increased for research)
|
||||
logger.info("✅ Gemini Grounded Provider initialized with native Google Search grounding")
|
||||
|
||||
async def generate_grounded_content(
|
||||
@@ -239,8 +239,8 @@ class GeminiGroundedProvider:
|
||||
logger.info(f"Search queries: {grounding_metadata.web_search_queries}")
|
||||
|
||||
# Extract sources from grounding chunks
|
||||
sources = [] # Initialize sources list
|
||||
if hasattr(grounding_metadata, 'grounding_chunks') and grounding_metadata.grounding_chunks:
|
||||
sources = []
|
||||
for i, chunk in enumerate(grounding_metadata.grounding_chunks):
|
||||
logger.info(f"Chunk {i} attributes: {dir(chunk)}")
|
||||
if hasattr(chunk, 'web'):
|
||||
@@ -251,15 +251,29 @@ class GeminiGroundedProvider:
|
||||
'type': 'web'
|
||||
}
|
||||
sources.append(source)
|
||||
result['sources'] = sources
|
||||
logger.info(f"Extracted {len(sources)} sources")
|
||||
logger.info(f"Extracted {len(sources)} sources from grounding chunks")
|
||||
else:
|
||||
logger.error("❌ CRITICAL: No grounding chunks found in response")
|
||||
logger.error(f"Grounding metadata structure: {dir(grounding_metadata)}")
|
||||
if hasattr(grounding_metadata, 'grounding_chunks'):
|
||||
logger.error(f"Grounding chunks type: {type(grounding_metadata.grounding_chunks)}")
|
||||
logger.error(f"Grounding chunks value: {grounding_metadata.grounding_chunks}")
|
||||
raise ValueError("No grounding chunks found - grounding is not working properly")
|
||||
logger.warning("⚠️ No grounding chunks found - this is normal for some queries")
|
||||
logger.info(f"Grounding metadata available fields: {[attr for attr in dir(grounding_metadata) if not attr.startswith('_')]}")
|
||||
|
||||
# Check if we have search queries - this means Google Search was triggered
|
||||
if hasattr(grounding_metadata, 'web_search_queries') and grounding_metadata.web_search_queries:
|
||||
logger.info(f"✅ Google Search was triggered with {len(grounding_metadata.web_search_queries)} queries")
|
||||
# Create sources based on search queries
|
||||
for i, query in enumerate(grounding_metadata.web_search_queries[:5]): # Limit to 5 sources
|
||||
source = {
|
||||
'index': i,
|
||||
'title': f"Search: {query}",
|
||||
'url': f"https://www.google.com/search?q={query.replace(' ', '+')}",
|
||||
'type': 'search_query',
|
||||
'query': query
|
||||
}
|
||||
sources.append(source)
|
||||
logger.info(f"Created {len(sources)} sources from search queries")
|
||||
else:
|
||||
logger.warning("⚠️ No search queries found either - grounding may not have been triggered")
|
||||
|
||||
result['sources'] = sources
|
||||
|
||||
# Extract citations from grounding supports
|
||||
if hasattr(grounding_metadata, 'grounding_supports') and grounding_metadata.grounding_supports:
|
||||
@@ -278,12 +292,37 @@ class GeminiGroundedProvider:
|
||||
result['citations'] = citations
|
||||
logger.info(f"Extracted {len(citations)} citations")
|
||||
else:
|
||||
logger.error("❌ CRITICAL: No grounding supports found in response")
|
||||
logger.error(f"Grounding metadata structure: {dir(grounding_metadata)}")
|
||||
if hasattr(grounding_metadata, 'grounding_supports'):
|
||||
logger.error(f"Grounding supports type: {type(grounding_metadata.grounding_supports)}")
|
||||
logger.error(f"Grounding supports value: {grounding_metadata.grounding_supports}")
|
||||
raise ValueError("No grounding supports found - grounding is not working properly")
|
||||
logger.warning("⚠️ No grounding supports found - this is normal when no web sources are retrieved")
|
||||
# Create basic citations from the content if we have sources
|
||||
if sources:
|
||||
citations = []
|
||||
for i, source in enumerate(sources[:3]): # Limit to 3 citations
|
||||
citation = {
|
||||
'type': 'reference',
|
||||
'start_index': 0,
|
||||
'end_index': 0,
|
||||
'text': f"Source {i+1}",
|
||||
'source_indices': [i],
|
||||
'reference': f"Source {i+1}",
|
||||
'source': source
|
||||
}
|
||||
citations.append(citation)
|
||||
result['citations'] = citations
|
||||
logger.info(f"Created {len(citations)} basic citations from sources")
|
||||
else:
|
||||
result['citations'] = []
|
||||
logger.info("No citations created - no sources available")
|
||||
|
||||
# Extract search entry point for UI display
|
||||
if hasattr(grounding_metadata, 'search_entry_point') and grounding_metadata.search_entry_point:
|
||||
if hasattr(grounding_metadata.search_entry_point, 'rendered_content'):
|
||||
result['search_widget'] = grounding_metadata.search_entry_point.rendered_content
|
||||
logger.info("✅ Extracted search widget HTML for UI display")
|
||||
|
||||
# Extract search queries for reference
|
||||
if hasattr(grounding_metadata, 'web_search_queries') and grounding_metadata.web_search_queries:
|
||||
result['search_queries'] = grounding_metadata.web_search_queries
|
||||
logger.info(f"✅ Extracted {len(grounding_metadata.web_search_queries)} search queries")
|
||||
|
||||
logger.info(f"✅ Successfully extracted {len(result['sources'])} sources and {len(result['citations'])} citations from grounding metadata")
|
||||
logger.info(f"Sources: {result['sources']}")
|
||||
|
||||
@@ -389,43 +389,13 @@ def gemini_structured_json_response(prompt, schema, temperature=0.7, top_p=0.9,
|
||||
config=generation_config,
|
||||
)
|
||||
|
||||
# Add debugging for response
|
||||
logger.info("Gemini response | type=%s | has_text=%s | has_parsed=%s",
|
||||
type(response), hasattr(response, 'text'), hasattr(response, 'parsed'))
|
||||
|
||||
if hasattr(response, 'text'):
|
||||
logger.info(f"Gemini response.text: {repr(response.text)}")
|
||||
if hasattr(response, 'parsed'):
|
||||
logger.info(f"Gemini response.parsed: {repr(response.parsed)}")
|
||||
|
||||
# According to the documentation, we should use response.parsed for structured output
|
||||
if hasattr(response, 'parsed') and response.parsed is not None:
|
||||
logger.info("Using response.parsed for structured output")
|
||||
return response.parsed
|
||||
|
||||
# Fallback to text if parsed is not available
|
||||
if hasattr(response, 'text') and response.text:
|
||||
logger.info("Falling back to response.text parsing")
|
||||
text = response.text.strip()
|
||||
|
||||
# Strip markdown code fences if present
|
||||
if text.startswith('```'):
|
||||
if text.lower().startswith('```json'):
|
||||
text = text[7:]
|
||||
else:
|
||||
text = text[3:]
|
||||
if text.endswith('```'):
|
||||
text = text[:-3]
|
||||
text = text.strip()
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse response.text as JSON: {e}")
|
||||
return {"error": f"Failed to parse JSON response: {e}", "raw_response": text[:500]}
|
||||
|
||||
logger.error("No valid response content found")
|
||||
return {"error": "No valid response content found", "raw_response": ""}
|
||||
logger.error("No valid structured response content found")
|
||||
return {"error": "No valid structured response content found"}
|
||||
|
||||
except ValueError as e:
|
||||
# API key related errors
|
||||
|
||||
@@ -45,8 +45,7 @@ class GoogleSearchService:
|
||||
self.base_url = "https://www.googleapis.com/customsearch/v1"
|
||||
|
||||
if not self.api_key or not self.search_engine_id:
|
||||
logger.warning("Google Search API credentials not configured. Service will use fallback methods.")
|
||||
self.enabled = False
|
||||
raise ValueError("Google Search API credentials not configured. Please set GOOGLE_SEARCH_API_KEY and GOOGLE_SEARCH_ENGINE_ID environment variables.")
|
||||
else:
|
||||
self.enabled = True
|
||||
logger.info("Google Search Service initialized successfully")
|
||||
@@ -69,8 +68,7 @@ class GoogleSearchService:
|
||||
List of search results with credibility scoring
|
||||
"""
|
||||
if not self.enabled:
|
||||
logger.warning("Google Search Service not enabled, using fallback research")
|
||||
return await self._fallback_research(topic, industry)
|
||||
raise RuntimeError("Google Search Service is not enabled. Please configure API credentials.")
|
||||
|
||||
try:
|
||||
# Construct industry-specific search query
|
||||
@@ -99,7 +97,7 @@ class GoogleSearchService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Google search failed: {str(e)}")
|
||||
return await self._fallback_research(topic, industry)
|
||||
raise RuntimeError(f"Google search failed: {str(e)}")
|
||||
|
||||
def _build_search_query(self, topic: str, industry: str) -> str:
|
||||
"""
|
||||
@@ -465,45 +463,6 @@ class GoogleSearchService:
|
||||
"statistics": statistics
|
||||
}
|
||||
|
||||
async def _fallback_research(self, topic: str, industry: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Fallback research method when Google Search is not available.
|
||||
|
||||
Args:
|
||||
topic: The research topic
|
||||
industry: The industry context
|
||||
|
||||
Returns:
|
||||
Fallback research data
|
||||
"""
|
||||
logger.info(f"Using fallback research for {topic} in {industry}")
|
||||
|
||||
return {
|
||||
"sources": [
|
||||
{
|
||||
"title": f"Industry insights on {topic} in {industry}",
|
||||
"url": f"https://example.com/{topic.lower().replace(' ', '-')}",
|
||||
"content": f"Professional insights and trends related to {topic} in the {industry} sector...",
|
||||
"relevance_score": 0.8,
|
||||
"credibility_score": 0.6,
|
||||
"domain_authority": 0.5,
|
||||
"source_type": "general",
|
||||
"grounding_enabled": False
|
||||
}
|
||||
],
|
||||
"key_insights": [
|
||||
f"{topic} is transforming {industry} operations",
|
||||
f"Industry leaders are investing in {topic}",
|
||||
f"Expected growth in {topic} adoption within {industry}"
|
||||
],
|
||||
"statistics": [
|
||||
f"85% of {industry} companies are exploring {topic}",
|
||||
f"Investment in {topic} increased by 40% this year"
|
||||
],
|
||||
"grounding_enabled": False,
|
||||
"search_query": f"{topic} {industry} trends",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
async def test_api_connection(self) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -513,11 +472,7 @@ class GoogleSearchService:
|
||||
Test results and status information
|
||||
"""
|
||||
if not self.enabled:
|
||||
return {
|
||||
"status": "disabled",
|
||||
"message": "Google Search API credentials not configured",
|
||||
"enabled": False
|
||||
}
|
||||
raise RuntimeError("Google Search Service is not enabled. Please configure API credentials.")
|
||||
|
||||
try:
|
||||
# Perform a simple test search
|
||||
|
||||
@@ -91,6 +91,37 @@ def setup_monitoring_tables():
|
||||
print(" Monitoring will be disabled. Continuing startup...")
|
||||
return True # Don't fail startup for monitoring issues
|
||||
|
||||
def setup_billing_tables():
|
||||
"""Set up billing and subscription database tables."""
|
||||
print("💳 Setting up billing and subscription tables...")
|
||||
|
||||
try:
|
||||
# Import and run the billing table creation
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
from scripts.create_billing_tables import create_billing_tables, check_existing_tables
|
||||
from services.database import DATABASE_URL
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
# Create engine to check existing tables
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
|
||||
# Check existing tables
|
||||
if not check_existing_tables(engine):
|
||||
print("✅ Billing tables already exist, skipping creation")
|
||||
return True
|
||||
|
||||
if create_billing_tables():
|
||||
print("✅ Billing and subscription tables created successfully!")
|
||||
return True
|
||||
else:
|
||||
print("⚠️ Warning: Failed to create billing tables, continuing anyway...")
|
||||
return True # Don't fail startup for billing issues
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Could not set up billing tables: {e}")
|
||||
print(" Billing system will be disabled. Continuing startup...")
|
||||
return True # Don't fail startup for billing issues
|
||||
|
||||
def setup_monitoring_middleware():
|
||||
"""Set up monitoring middleware in app.py if not already present."""
|
||||
print("🔍 Setting up API monitoring middleware...")
|
||||
@@ -168,7 +199,8 @@ def check_dependencies():
|
||||
'openai',
|
||||
'google.generativeai',
|
||||
'anthropic',
|
||||
'mistralai'
|
||||
'mistralai',
|
||||
'sqlalchemy'
|
||||
]
|
||||
|
||||
missing_packages = []
|
||||
@@ -212,6 +244,9 @@ def setup_environment():
|
||||
setup_monitoring_tables()
|
||||
setup_monitoring_middleware()
|
||||
|
||||
# Set up billing and subscription system
|
||||
setup_billing_tables()
|
||||
|
||||
print("✅ Environment setup complete")
|
||||
|
||||
def verify_persona_tables():
|
||||
@@ -238,6 +273,35 @@ def verify_persona_tables():
|
||||
print(f"⚠️ Warning: Could not verify persona tables: {e}")
|
||||
return False
|
||||
|
||||
def verify_billing_tables():
|
||||
"""Verify that billing and subscription tables exist and are accessible."""
|
||||
print("🔍 Verifying billing and subscription tables...")
|
||||
try:
|
||||
from services.database import get_db_session
|
||||
from models.subscription_models import (
|
||||
SubscriptionPlan, UserSubscription, APIUsageLog,
|
||||
UsageSummary, APIProviderPricing, UsageAlert
|
||||
)
|
||||
|
||||
session = get_db_session()
|
||||
if session:
|
||||
# Try to query all billing tables to verify they exist
|
||||
session.query(SubscriptionPlan).first()
|
||||
session.query(UserSubscription).first()
|
||||
session.query(APIUsageLog).first()
|
||||
session.query(UsageSummary).first()
|
||||
session.query(APIProviderPricing).first()
|
||||
session.query(UsageAlert).first()
|
||||
session.close()
|
||||
print("✅ All billing and subscription tables verified successfully")
|
||||
return True
|
||||
else:
|
||||
print("⚠️ Warning: Could not get database session")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Could not verify billing tables: {e}")
|
||||
return False
|
||||
|
||||
def start_backend(enable_reload=False):
|
||||
"""Start the backend server."""
|
||||
print("🚀 Starting ALwrity Backend...")
|
||||
@@ -276,11 +340,16 @@ def start_backend(enable_reload=False):
|
||||
# Verify persona tables exist
|
||||
verify_persona_tables()
|
||||
|
||||
# Verify billing tables exist
|
||||
verify_billing_tables()
|
||||
|
||||
print("\n🌐 Backend is starting...")
|
||||
print(" 📖 API Documentation: http://localhost:8000/api/docs")
|
||||
print(" 🔍 Health Check: http://localhost:8000/health")
|
||||
print(" 📊 ReDoc: http://localhost:8000/api/redoc")
|
||||
print(" 📈 API Monitoring: http://localhost:8000/api/content-planning/monitoring/health")
|
||||
print(" 💳 Billing Dashboard: http://localhost:8000/api/subscription/plans")
|
||||
print(" 📊 Usage Tracking: http://localhost:8000/api/subscription/usage/demo")
|
||||
print("\n⏹️ Press Ctrl+C to stop the server")
|
||||
print("=" * 60)
|
||||
print("\n💡 Usage:")
|
||||
|
||||
43
backend/test_detailed.py
Normal file
43
backend/test_detailed.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Test the research endpoint with more detailed output
|
||||
url = "http://localhost:8000/api/blog/research"
|
||||
payload = {
|
||||
"keywords": ["AI content generation", "blog writing"],
|
||||
"topic": "ALwrity content generation",
|
||||
"industry": "Technology",
|
||||
"target_audience": "content creators"
|
||||
}
|
||||
|
||||
try:
|
||||
print("Sending request to research endpoint...")
|
||||
response = requests.post(url, json=payload, timeout=60)
|
||||
print(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print("\n=== FULL RESPONSE ===")
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
# Check if we got the expected fields
|
||||
expected_fields = ['success', 'sources', 'keyword_analysis', 'competitor_analysis', 'suggested_angles', 'search_widget', 'search_queries']
|
||||
print(f"\n=== FIELD ANALYSIS ===")
|
||||
for field in expected_fields:
|
||||
value = data.get(field)
|
||||
if field == 'sources':
|
||||
print(f"{field}: {len(value) if value else 0} items")
|
||||
elif field == 'search_queries':
|
||||
print(f"{field}: {len(value) if value else 0} items")
|
||||
elif field == 'search_widget':
|
||||
print(f"{field}: {'Present' if value else 'Missing'}")
|
||||
else:
|
||||
print(f"{field}: {type(value).__name__} - {str(value)[:100]}...")
|
||||
|
||||
else:
|
||||
print(f"Error Response: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Request failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
60
backend/test_gemini_direct.py
Normal file
60
backend/test_gemini_direct.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
from services.llm_providers.gemini_grounded_provider import GeminiGroundedProvider
|
||||
|
||||
async def test_gemini_direct():
|
||||
gemini = GeminiGroundedProvider()
|
||||
|
||||
prompt = """
|
||||
Research the topic "AI content generation" in the Technology industry for content creators audience. Provide a comprehensive analysis including:
|
||||
|
||||
1. Current trends and insights (2024-2025)
|
||||
2. Key statistics and data points with sources
|
||||
3. Industry expert opinions and quotes
|
||||
4. Recent developments and news
|
||||
5. Market analysis and forecasts
|
||||
6. Best practices and case studies
|
||||
7. Keyword analysis: primary, secondary, and long-tail opportunities
|
||||
8. Competitor analysis: top players and content gaps
|
||||
9. Content angle suggestions: 5 compelling angles for blog posts
|
||||
|
||||
Focus on factual, up-to-date information from credible sources.
|
||||
Include specific data points, percentages, and recent developments.
|
||||
Structure your response with clear sections for each analysis area.
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await gemini.generate_grounded_content(
|
||||
prompt=prompt,
|
||||
content_type="research",
|
||||
max_tokens=2000
|
||||
)
|
||||
|
||||
print("=== GEMINI RESULT ===")
|
||||
print(f"Type: {type(result)}")
|
||||
print(f"Keys: {list(result.keys()) if isinstance(result, dict) else 'Not a dict'}")
|
||||
|
||||
if isinstance(result, dict):
|
||||
print(f"Sources count: {len(result.get('sources', []))}")
|
||||
print(f"Search queries count: {len(result.get('search_queries', []))}")
|
||||
print(f"Has search widget: {bool(result.get('search_widget'))}")
|
||||
print(f"Content length: {len(result.get('content', ''))}")
|
||||
|
||||
print("\n=== FIRST SOURCE ===")
|
||||
sources = result.get('sources', [])
|
||||
if sources:
|
||||
print(f"Source: {sources[0]}")
|
||||
|
||||
print("\n=== SEARCH QUERIES (First 3) ===")
|
||||
queries = result.get('search_queries', [])
|
||||
for i, query in enumerate(queries[:3]):
|
||||
print(f"{i+1}. {query}")
|
||||
else:
|
||||
print(f"Result is not a dict: {result}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_gemini_direct())
|
||||
58
backend/test_research.py
Normal file
58
backend/test_research.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Test the research endpoint
|
||||
url = "http://localhost:8000/api/blog/research"
|
||||
payload = {
|
||||
"keywords": ["AI content generation", "blog writing"],
|
||||
"topic": "ALwrity content generation",
|
||||
"industry": "Technology",
|
||||
"target_audience": "content creators"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload)
|
||||
print(f"Status Code: {response.status_code}")
|
||||
print(f"Response Headers: {dict(response.headers)}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print("\n=== RESEARCH RESPONSE ===")
|
||||
print(f"Success: {data.get('success')}")
|
||||
print(f"Sources Count: {len(data.get('sources', []))}")
|
||||
print(f"Search Queries Count: {len(data.get('search_queries', []))}")
|
||||
print(f"Has Search Widget: {bool(data.get('search_widget'))}")
|
||||
print(f"Suggested Angles Count: {len(data.get('suggested_angles', []))}")
|
||||
|
||||
print("\n=== SOURCES ===")
|
||||
for i, source in enumerate(data.get('sources', [])[:3]):
|
||||
print(f"Source {i+1}: {source.get('title', 'No title')}")
|
||||
print(f" URL: {source.get('url', 'No URL')}")
|
||||
print(f" Type: {source.get('type', 'Unknown')}")
|
||||
|
||||
print("\n=== SEARCH QUERIES (First 5) ===")
|
||||
for i, query in enumerate(data.get('search_queries', [])[:5]):
|
||||
print(f"{i+1}. {query}")
|
||||
|
||||
print("\n=== SUGGESTED ANGLES ===")
|
||||
for i, angle in enumerate(data.get('suggested_angles', [])[:3]):
|
||||
print(f"{i+1}. {angle}")
|
||||
|
||||
print("\n=== KEYWORD ANALYSIS ===")
|
||||
kw_analysis = data.get('keyword_analysis', {})
|
||||
print(f"Primary: {kw_analysis.get('primary', [])}")
|
||||
print(f"Secondary: {kw_analysis.get('secondary', [])}")
|
||||
print(f"Search Intent: {kw_analysis.get('search_intent', 'Unknown')}")
|
||||
|
||||
print("\n=== SEARCH WIDGET (First 200 chars) ===")
|
||||
widget = data.get('search_widget', '')
|
||||
if widget:
|
||||
print(widget[:200] + "..." if len(widget) > 200 else widget)
|
||||
else:
|
||||
print("No search widget provided")
|
||||
|
||||
else:
|
||||
print(f"Error: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Request failed: {e}")
|
||||
115
backend/test_research_analysis.py
Normal file
115
backend/test_research_analysis.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Test the research endpoint and capture full response
|
||||
url = "http://localhost:8000/api/blog/research"
|
||||
payload = {
|
||||
"keywords": ["AI content generation", "blog writing"],
|
||||
"topic": "ALwrity content generation",
|
||||
"industry": "Technology",
|
||||
"target_audience": "content creators"
|
||||
}
|
||||
|
||||
try:
|
||||
print("Sending request to research endpoint...")
|
||||
response = requests.post(url, json=payload, timeout=120)
|
||||
print(f"Status Code: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Create analysis file with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"research_analysis_{timestamp}.json"
|
||||
|
||||
# Save full response to file
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"\n=== RESEARCH RESPONSE ANALYSIS ===")
|
||||
print(f"✅ Full response saved to: {filename}")
|
||||
print(f"Success: {data.get('success')}")
|
||||
print(f"Sources Count: {len(data.get('sources', []))}")
|
||||
print(f"Search Queries Count: {len(data.get('search_queries', []))}")
|
||||
print(f"Has Search Widget: {bool(data.get('search_widget'))}")
|
||||
print(f"Suggested Angles Count: {len(data.get('suggested_angles', []))}")
|
||||
|
||||
print(f"\n=== SOURCES ANALYSIS ===")
|
||||
sources = data.get('sources', [])
|
||||
for i, source in enumerate(sources[:5]): # Show first 5
|
||||
print(f"Source {i+1}: {source.get('title', 'No title')}")
|
||||
print(f" URL: {source.get('url', 'No URL')[:100]}...")
|
||||
print(f" Type: {source.get('type', 'Unknown')}")
|
||||
print(f" Credibility: {source.get('credibility_score', 'N/A')}")
|
||||
|
||||
print(f"\n=== SEARCH QUERIES ANALYSIS ===")
|
||||
queries = data.get('search_queries', [])
|
||||
print(f"Total queries: {len(queries)}")
|
||||
for i, query in enumerate(queries[:10]): # Show first 10
|
||||
print(f"{i+1:2d}. {query}")
|
||||
|
||||
print(f"\n=== SEARCH WIDGET ANALYSIS ===")
|
||||
widget = data.get('search_widget', '')
|
||||
if widget:
|
||||
print(f"Widget HTML length: {len(widget)} characters")
|
||||
print(f"Contains Google branding: {'Google' in widget}")
|
||||
print(f"Contains search chips: {'chip' in widget}")
|
||||
print(f"Contains carousel: {'carousel' in widget}")
|
||||
print(f"First 200 chars: {widget[:200]}...")
|
||||
else:
|
||||
print("No search widget provided")
|
||||
|
||||
print(f"\n=== KEYWORD ANALYSIS ===")
|
||||
kw_analysis = data.get('keyword_analysis', {})
|
||||
print(f"Primary keywords: {kw_analysis.get('primary', [])}")
|
||||
print(f"Secondary keywords: {kw_analysis.get('secondary', [])}")
|
||||
print(f"Long-tail keywords: {kw_analysis.get('long_tail', [])}")
|
||||
print(f"Search intent: {kw_analysis.get('search_intent', 'Unknown')}")
|
||||
print(f"Difficulty score: {kw_analysis.get('difficulty', 'N/A')}")
|
||||
|
||||
print(f"\n=== SUGGESTED ANGLES ===")
|
||||
angles = data.get('suggested_angles', [])
|
||||
for i, angle in enumerate(angles):
|
||||
print(f"{i+1}. {angle}")
|
||||
|
||||
print(f"\n=== UI REPRESENTATION RECOMMENDATIONS ===")
|
||||
print("Based on the response, here's what should be displayed in the Editor UI:")
|
||||
print(f"1. Research Sources Panel: {len(sources)} real web sources")
|
||||
print(f"2. Search Widget: Interactive Google search chips ({len(queries)} queries)")
|
||||
print(f"3. Keyword Analysis: Primary/Secondary/Long-tail breakdown")
|
||||
print(f"4. Content Angles: {len(angles)} suggested blog post angles")
|
||||
print(f"5. Search Queries: {len(queries)} research queries for reference")
|
||||
|
||||
# Additional analysis for UI components
|
||||
print(f"\n=== UI COMPONENT BREAKDOWN ===")
|
||||
|
||||
# Sources for UI
|
||||
print("SOURCES FOR UI:")
|
||||
for i, source in enumerate(sources[:3]):
|
||||
print(f" - {source.get('title')} (Credibility: {source.get('credibility_score')})")
|
||||
|
||||
# Search widget for UI
|
||||
print(f"\nSEARCH WIDGET FOR UI:")
|
||||
print(f" - HTML length: {len(widget)} chars")
|
||||
print(f" - Can be embedded directly in UI")
|
||||
print(f" - Contains {len(queries)} search suggestions")
|
||||
|
||||
# Keywords for UI
|
||||
print(f"\nKEYWORDS FOR UI:")
|
||||
print(f" - Primary: {', '.join(kw_analysis.get('primary', []))}")
|
||||
print(f" - Secondary: {', '.join(kw_analysis.get('secondary', []))}")
|
||||
print(f" - Long-tail: {', '.join(kw_analysis.get('long_tail', []))}")
|
||||
|
||||
# Angles for UI
|
||||
print(f"\nCONTENT ANGLES FOR UI:")
|
||||
for i, angle in enumerate(angles[:3]):
|
||||
print(f" - {angle}")
|
||||
|
||||
else:
|
||||
print(f"Error: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Request failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
280
backend/verify_billing_setup.py
Normal file
280
backend/verify_billing_setup.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Comprehensive verification script for billing and subscription system setup.
|
||||
Checks that all files are created, tables exist, and the system is properly integrated.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def check_file_exists(file_path, description):
|
||||
"""Check if a file exists and report status."""
|
||||
if os.path.exists(file_path):
|
||||
print(f"✅ {description}: {file_path}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ {description}: {file_path} - NOT FOUND")
|
||||
return False
|
||||
|
||||
def check_file_content(file_path, search_terms, description):
|
||||
"""Check if file contains expected content."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
missing_terms = []
|
||||
for term in search_terms:
|
||||
if term not in content:
|
||||
missing_terms.append(term)
|
||||
|
||||
if not missing_terms:
|
||||
print(f"✅ {description}: All expected content found")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ {description}: Missing content - {missing_terms}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ {description}: Error reading file - {e}")
|
||||
return False
|
||||
|
||||
def check_database_tables():
|
||||
"""Check if billing database tables exist."""
|
||||
print("\n🗄️ Checking Database Tables:")
|
||||
print("-" * 30)
|
||||
|
||||
try:
|
||||
# Add backend to path
|
||||
backend_dir = Path(__file__).parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from services.database import get_db_session, DATABASE_URL
|
||||
from sqlalchemy import text
|
||||
|
||||
session = get_db_session()
|
||||
if not session:
|
||||
print("❌ Could not get database session")
|
||||
return False
|
||||
|
||||
# Check for billing tables
|
||||
tables_query = text("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND (
|
||||
name LIKE '%subscription%' OR
|
||||
name LIKE '%usage%' OR
|
||||
name LIKE '%billing%' OR
|
||||
name LIKE '%pricing%' OR
|
||||
name LIKE '%alert%'
|
||||
)
|
||||
ORDER BY name
|
||||
""")
|
||||
|
||||
result = session.execute(tables_query)
|
||||
tables = result.fetchall()
|
||||
|
||||
expected_tables = [
|
||||
'api_provider_pricing',
|
||||
'api_usage_logs',
|
||||
'subscription_plans',
|
||||
'usage_alerts',
|
||||
'usage_summaries',
|
||||
'user_subscriptions'
|
||||
]
|
||||
|
||||
found_tables = [t[0] for t in tables]
|
||||
print(f"Found tables: {found_tables}")
|
||||
|
||||
missing_tables = [t for t in expected_tables if t not in found_tables]
|
||||
if missing_tables:
|
||||
print(f"❌ Missing tables: {missing_tables}")
|
||||
return False
|
||||
|
||||
# Check table data
|
||||
for table in ['subscription_plans', 'api_provider_pricing']:
|
||||
count_query = text(f"SELECT COUNT(*) FROM {table}")
|
||||
result = session.execute(count_query)
|
||||
count = result.fetchone()[0]
|
||||
print(f"✅ {table}: {count} records")
|
||||
|
||||
session.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Database check failed: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Main verification function."""
|
||||
|
||||
print("🔍 ALwrity Billing & Subscription System Setup Verification")
|
||||
print("=" * 70)
|
||||
|
||||
backend_dir = Path(__file__).parent
|
||||
|
||||
# Files to check
|
||||
files_to_check = [
|
||||
(backend_dir / "models" / "subscription_models.py", "Subscription Models"),
|
||||
(backend_dir / "services" / "pricing_service.py", "Pricing Service"),
|
||||
(backend_dir / "services" / "usage_tracking_service.py", "Usage Tracking Service"),
|
||||
(backend_dir / "services" / "subscription_exception_handler.py", "Exception Handler"),
|
||||
(backend_dir / "api" / "subscription_api.py", "Subscription API"),
|
||||
(backend_dir / "scripts" / "create_billing_tables.py", "Billing Migration Script"),
|
||||
(backend_dir / "scripts" / "create_subscription_tables.py", "Subscription Migration Script"),
|
||||
(backend_dir / "start_alwrity_backend.py", "Backend Startup Script"),
|
||||
]
|
||||
|
||||
# Check file existence
|
||||
print("\n📁 Checking File Existence:")
|
||||
print("-" * 30)
|
||||
files_exist = 0
|
||||
for file_path, description in files_to_check:
|
||||
if check_file_exists(file_path, description):
|
||||
files_exist += 1
|
||||
|
||||
# Check content of key files
|
||||
print("\n📝 Checking File Content:")
|
||||
print("-" * 30)
|
||||
|
||||
content_checks = [
|
||||
(
|
||||
backend_dir / "models" / "subscription_models.py",
|
||||
["SubscriptionPlan", "APIUsageLog", "UsageSummary", "APIProviderPricing"],
|
||||
"Subscription Models Content"
|
||||
),
|
||||
(
|
||||
backend_dir / "services" / "pricing_service.py",
|
||||
["calculate_api_cost", "check_usage_limits", "initialize_default_pricing"],
|
||||
"Pricing Service Content"
|
||||
),
|
||||
(
|
||||
backend_dir / "services" / "usage_tracking_service.py",
|
||||
["track_api_usage", "get_user_usage_stats", "enforce_usage_limits"],
|
||||
"Usage Tracking Content"
|
||||
),
|
||||
(
|
||||
backend_dir / "api" / "subscription_api.py",
|
||||
["get_user_usage", "get_subscription_plans", "get_dashboard_data"],
|
||||
"API Endpoints Content"
|
||||
),
|
||||
(
|
||||
backend_dir / "start_alwrity_backend.py",
|
||||
["setup_billing_tables", "verify_billing_tables"],
|
||||
"Backend Startup Integration"
|
||||
)
|
||||
]
|
||||
|
||||
content_valid = 0
|
||||
for file_path, search_terms, description in content_checks:
|
||||
if os.path.exists(file_path):
|
||||
if check_file_content(file_path, search_terms, description):
|
||||
content_valid += 1
|
||||
else:
|
||||
print(f"❌ {description}: File not found")
|
||||
|
||||
# Check database tables
|
||||
database_ok = check_database_tables()
|
||||
|
||||
# Check middleware integration
|
||||
print("\n🔧 Checking Middleware Integration:")
|
||||
print("-" * 30)
|
||||
|
||||
middleware_file = backend_dir / "middleware" / "monitoring_middleware.py"
|
||||
middleware_terms = [
|
||||
"UsageTrackingService",
|
||||
"detect_api_provider",
|
||||
"track_api_usage",
|
||||
"check_usage_limits_middleware"
|
||||
]
|
||||
|
||||
middleware_ok = check_file_content(
|
||||
middleware_file,
|
||||
middleware_terms,
|
||||
"Middleware Integration"
|
||||
)
|
||||
|
||||
# Check app.py integration
|
||||
print("\n🚀 Checking FastAPI Integration:")
|
||||
print("-" * 30)
|
||||
|
||||
app_file = backend_dir / "app.py"
|
||||
app_terms = [
|
||||
"from api.subscription_api import router as subscription_router",
|
||||
"app.include_router(subscription_router)"
|
||||
]
|
||||
|
||||
app_ok = check_file_content(
|
||||
app_file,
|
||||
app_terms,
|
||||
"FastAPI App Integration"
|
||||
)
|
||||
|
||||
# Check database service integration
|
||||
print("\n💾 Checking Database Integration:")
|
||||
print("-" * 30)
|
||||
|
||||
db_file = backend_dir / "services" / "database.py"
|
||||
db_terms = [
|
||||
"from models.subscription_models import Base as SubscriptionBase",
|
||||
"SubscriptionBase.metadata.create_all(bind=engine)"
|
||||
]
|
||||
|
||||
db_ok = check_file_content(
|
||||
db_file,
|
||||
db_terms,
|
||||
"Database Service Integration"
|
||||
)
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
print("📊 VERIFICATION SUMMARY")
|
||||
print("=" * 70)
|
||||
|
||||
total_files = len(files_to_check)
|
||||
total_content = len(content_checks)
|
||||
|
||||
print(f"Files Created: {files_exist}/{total_files}")
|
||||
print(f"Content Valid: {content_valid}/{total_content}")
|
||||
print(f"Database Tables: {'✅' if database_ok else '❌'}")
|
||||
print(f"Middleware Integration: {'✅' if middleware_ok else '❌'}")
|
||||
print(f"FastAPI Integration: {'✅' if app_ok else '❌'}")
|
||||
print(f"Database Integration: {'✅' if db_ok else '❌'}")
|
||||
|
||||
# Overall status
|
||||
all_checks = [
|
||||
files_exist == total_files,
|
||||
content_valid == total_content,
|
||||
database_ok,
|
||||
middleware_ok,
|
||||
app_ok,
|
||||
db_ok
|
||||
]
|
||||
|
||||
if all(all_checks):
|
||||
print("\n🎉 ALL CHECKS PASSED!")
|
||||
print("✅ Billing and subscription system setup is complete and ready to use.")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("🚀 NEXT STEPS:")
|
||||
print("=" * 70)
|
||||
print("1. Start the backend server:")
|
||||
print(" python start_alwrity_backend.py")
|
||||
print("\n2. Test the API endpoints:")
|
||||
print(" GET http://localhost:8000/api/subscription/plans")
|
||||
print(" GET http://localhost:8000/api/subscription/usage/demo")
|
||||
print(" GET http://localhost:8000/api/subscription/dashboard/demo")
|
||||
print(" GET http://localhost:8000/api/subscription/pricing")
|
||||
print("\n3. Access the frontend billing dashboard")
|
||||
print("4. Monitor usage through the API monitoring middleware")
|
||||
print("5. Set up user identification for production use")
|
||||
print("=" * 70)
|
||||
|
||||
else:
|
||||
print("\n❌ SOME CHECKS FAILED!")
|
||||
print("Please review the errors above and fix any issues.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
237
docs/AI_BLOG_WRITER_IMPLEMENTATION_SPEC.md
Normal file
237
docs/AI_BLOG_WRITER_IMPLEMENTATION_SPEC.md
Normal file
@@ -0,0 +1,237 @@
|
||||
## AI Blog Writer — Implementation Specification (Copilot-first, Research-led)
|
||||
|
||||
### Overview
|
||||
- **Goal**: Build a SOTA AI blog writer that guides non-technical users end-to-end: research → outline → section generation → quality/SEO → publishing.
|
||||
- **Approach**: Copilot-first UX using CopilotKit. Reuse LinkedIn assistive writing patterns: Google Search grounding, Exa research, hallucination detector, quality analysis, citations.
|
||||
- **User Interaction Model**: The user only talks to the Copilot; the editor reflects all state and changes via generative UI and HITL confirmations.
|
||||
|
||||
### Key Principles
|
||||
- **AI-first, HITL**: The assistant leads with intelligent suggestions; the user approves via render-and-wait HITL components where appropriate.
|
||||
- **Research fidelity**: Google grounding + Exa researcher; hallucination detection with claim verification; pervasive citations.
|
||||
- **Persona-aware**: Import blog writing persona from DB and apply it across planning/generation/optimizations.
|
||||
- **SEO-excellent**: Real-time SEO analysis, metadata generation, schema, and image alt handling.
|
||||
- **Publish-ready**: Smooth handoff to Wix/WordPress; preview and scheduling.
|
||||
|
||||
---
|
||||
|
||||
## 1) Workflow (4 Stages)
|
||||
|
||||
### Stage 1: Research & Strategy (AI Orchestration)
|
||||
Inputs
|
||||
- `keywords: string[]`, `industry: string`, `targetAudience: string`, `tone: string`, `wordCountTarget: number`, `userId`
|
||||
- Persona is fetched from DB and persisted in session
|
||||
|
||||
Backend/Services
|
||||
- Reuse LinkedIn research handler patterns: Google native grounding (Gemini provider), optional Exa research.
|
||||
- Reuse hallucination detector service and models: `/api/hallucination-detector/*` for claim extraction and verification.
|
||||
|
||||
CopilotKit Actions
|
||||
- `getPersonaFromDB(userId)` → persona constraints and style.
|
||||
- `analyzeKeywords(keywords, industry, audience)` → search intent, primary/secondary/long-tail, difficulty, volume.
|
||||
- `researchTopic(topic, depth, sources=['google','exa'])` → aggregated research sources (with credibility + timestamps).
|
||||
- `analyzeCompetitors(keywords, industry)` → top pages, headings used, gaps/opportunities.
|
||||
|
||||
Generative UI (render-only)
|
||||
- Research Summary card: sources, credibility score, proposed angles.
|
||||
- Suggested Keywords: chip list; add/remove HITL.
|
||||
|
||||
Suggestions (programmatic)
|
||||
- “Confirm research”, “Refine keywords”, “Add competitor”, “Proceed to outline”.
|
||||
|
||||
---
|
||||
|
||||
### Stage 2: Content Planning (AI + Human)
|
||||
Deliverables
|
||||
- Structured outline (H1/H2/H3), per-section key points, citations to use, target word counts.
|
||||
|
||||
CopilotKit Actions
|
||||
- `generateOutline(research, persona, wordCount)` → full outline with per-section targets and suggested refs.
|
||||
- `refineOutline(operation, sectionId, payload?)` → add/remove/move/merge sections (HITL diff in UI).
|
||||
- `attachReferences(sectionId, sourceIds[])` → associate sources to sections.
|
||||
|
||||
Generative UI (HITL)
|
||||
- Outline Editor: draggable sections/subsections, per-section references and target words, persona style hints.
|
||||
|
||||
Suggestions
|
||||
- “Generate [Section 1]”, “Regenerate [Section 2]”, “Attach sources to [Section]”, “Generate All Sections”.
|
||||
|
||||
---
|
||||
|
||||
### Stage 3: Content Generation (CopilotKit-only, no multi-agent)
|
||||
Deliverables
|
||||
- Long-form markdown content with inline citations, persona-aligned tone, and sectioned structure.
|
||||
|
||||
CopilotKit Actions
|
||||
- `generateSection(sectionPlan, keywords, tone, persona, refs[])` → returns markdown + inline cites.
|
||||
- `generateAllSections(outline)` → sequential section generation with progress render.
|
||||
- `optimizeSection(content, goals[])` → readability/EEAT/examples/data improvements; UI shows diff preview (HITL confirm).
|
||||
- `runHallucinationCheck(content)` → uses `/api/hallucination-detector/detect` to flag claims + propose fixes.
|
||||
|
||||
Editor/UI Updates
|
||||
- Per-section markdown tabs; word count; inline citation chips; section mini-SEO score.
|
||||
- DiffPreview component for any AI edit prior to apply.
|
||||
|
||||
Suggestions
|
||||
- “Add table/figure”, “Insert case study with source”, “Strengthen introduction”, “Tighten conclusion CTA”.
|
||||
|
||||
---
|
||||
|
||||
### Stage 4: Optimization & Publishing (AI + Human)
|
||||
SEO Optimization
|
||||
- `analyzeSEO(content, keywords)` → density, heading structure, links, readability, image alt coverage, overall SEO score.
|
||||
- `generateSEOMetadata(content, title, keywords)` → title options, meta description, OG/Twitter cards, schema Article/FAQ.
|
||||
- `applySEOFixes(suggestions[])` → diff preview + HITL apply.
|
||||
|
||||
Publishing
|
||||
- `prepareForPublish(platform: 'wix' | 'wordpress')` → HTML + images + metadata packaging.
|
||||
- `publishToPlatform(platform, schedule?)` → uses Wix/WordPress clients (ToBeMigrated integrations). Shows URL/status.
|
||||
|
||||
Suggestions
|
||||
- “Run SEO analysis”, “Apply recommended fixes”, “Generate metadata”, “Publish to WordPress”, “Schedule on Wix”.
|
||||
|
||||
---
|
||||
|
||||
## 2) SEO Tools Integration & Metadata
|
||||
|
||||
Existing Services to Wrap
|
||||
- Meta Description, OpenGraph, Image Alt, On-Page SEO, Technical SEO, Content Strategy (see `backend/services/seo_tools/*` and docs).
|
||||
|
||||
Unified Endpoints
|
||||
- `POST /api/blog/seo/analyze` → { seoScore, density, structure, readability, link suggestions, image alt status, recs }
|
||||
- `POST /api/blog/seo/metadata` → { titleOptions, metaDescriptionOptions, openGraph, twitterCard, schema: { Article, FAQ?, Breadcrumb, Org/Person } }
|
||||
|
||||
Editor SEO Panel
|
||||
- Live density and distribution, readability (Flesch-Kincaid), heading hierarchy, internal/external link suggestions.
|
||||
- One-click “Apply Fix” with diff preview.
|
||||
|
||||
Schema
|
||||
- Default Article schema; optional FAQ when Q&A snippets exist; Breadcrumb, Organization/Person as applicable.
|
||||
|
||||
---
|
||||
|
||||
## 3) Dedicated Blog Editor Design (Copilot-first)
|
||||
|
||||
Layout
|
||||
- Left: Markdown Editor (per-section tabs), word count, persona cues, inline citation chips.
|
||||
- Right: Live Preview (desktop/mobile), SEO SERP snippet preview, social preview (OG/Twitter).
|
||||
- Sidebar Panels: Research (sources, claims), SEO (scores/fixes), Media (AI images + alt text), History (versions).
|
||||
|
||||
Core Components
|
||||
- `BlogResearchCard` (render-only): sources, credibility scores, add-to-outline.
|
||||
- `OutlineEditor` (HITL): drag-drop H2/H3, per-section refs and target words.
|
||||
- `SectionEditor`: markdown area with persona/tone badges; per-section SEO mini-score.
|
||||
- `DiffPreview` (HITL): apply/reject AI edits.
|
||||
- `SEOPanel`: density/structure/readability + apply fix.
|
||||
- `MediaPanel`: AI images, compression, automatic alt-text.
|
||||
|
||||
CopilotKit Integrations
|
||||
- Suggestions: set programmatically (`useCopilotChatHeadless_c`) or via `CopilotSidebar` props.
|
||||
- Generative UI: `useCopilotAction({ render })` for research cards, outline editor, diff preview, publish dialog.
|
||||
- HITL: `renderAndWaitForResponse` for approvals at outline, diff apply, and publish steps.
|
||||
- References: CopilotKit docs — Frontend Actions, Generative UI, Suggestions, HITL.
|
||||
|
||||
Persistence
|
||||
- Persist outline, per-section content, references, persona snapshot, SEO state, metadata drafts.
|
||||
- Auto-save every 30s; version history for undo.
|
||||
|
||||
---
|
||||
|
||||
## 4) Backend APIs
|
||||
|
||||
New Blog Endpoints
|
||||
- `POST /api/blog/research` → inputs: keywords/industry/audience/tone/wordCount, personaId?; returns research bundle.
|
||||
- `POST /api/blog/outline/generate` → returns structured outline with targets and ref suggestions.
|
||||
- `POST /api/blog/outline/refine` → returns updated outline (operation-based).
|
||||
- `POST /api/blog/section/generate` → returns markdown + inline citations.
|
||||
- `POST /api/blog/section/optimize` → returns optimized content + rationale.
|
||||
- `POST /api/blog/quality/hallucination-check` → proxies hallucination detector results for blog.
|
||||
- `POST /api/blog/seo/analyze` → wraps SEO analyzers; returns scores/suggestions.
|
||||
- `POST /api/blog/seo/metadata` → returns title/meta/OG/Twitter/schema.
|
||||
- `POST /api/blog/publish` → platform: wix|wordpress, schedule?; returns URL/status.
|
||||
|
||||
Reuse
|
||||
- `/api/hallucination-detector/detect|extract-claims|verify-claim|health` (already implemented).
|
||||
|
||||
Models (indicative)
|
||||
- `BlogResearchRequest`, `BlogResearchResponse`
|
||||
- `BlogOutline`, `BlogOutlineRefinement`
|
||||
- `BlogSectionRequest`, `BlogSectionResponse`
|
||||
- `BlogSEOAnalysisRequest`, `BlogSEOMetadataResponse`
|
||||
|
||||
---
|
||||
|
||||
## 5) CopilotKit Action Inventory
|
||||
|
||||
Research
|
||||
- `getPersonaFromDB`, `analyzeKeywords`, `researchTopic`, `analyzeCompetitors`
|
||||
|
||||
Planning
|
||||
- `generateOutline`, `refineOutline`, `attachReferences`
|
||||
|
||||
Generation
|
||||
- `generateSection`, `generateAllSections`, `optimizeSection`, `runHallucinationCheck`
|
||||
|
||||
SEO
|
||||
- `analyzeSEO`, `generateSEOMetadata`, `applySEOFixes`
|
||||
|
||||
Publishing
|
||||
- `prepareForPublish`, `publishToPlatform`
|
||||
|
||||
UX/Render-only/HITL
|
||||
- `showResearchCard`, `showOutlineEditor`, `showDiffPreview`, `showSEOPanel`, `showPublishDialog`
|
||||
|
||||
---
|
||||
|
||||
## 6) Intelligent Suggestions (states)
|
||||
|
||||
Before research
|
||||
- “Load persona”, “Analyze keywords”, “Research topic”
|
||||
|
||||
After research
|
||||
- “Generate outline”, “Add competitor H2s”, “Attach sources”
|
||||
|
||||
Outline ready
|
||||
- “Generate [Section 1]”, “…”, “Generate all sections”
|
||||
|
||||
Draft ready
|
||||
- “Run fact-check”, “Run SEO analysis”, “Generate metadata”
|
||||
|
||||
Final
|
||||
- “Publish to WordPress”, “Schedule on Wix”
|
||||
|
||||
---
|
||||
|
||||
## 7) Delivery Plan / Milestones
|
||||
|
||||
Milestone 1: Research + Outline
|
||||
- Actions: persona load, analyze keywords, research topic, generate outline, outline editor (HITL)
|
||||
|
||||
Milestone 2: Section Generation + Quality
|
||||
- generateSection/generateAllSections, optimizeSection with diff preview, hallucination check + fixes
|
||||
|
||||
Milestone 3: SEO & Metadata
|
||||
- analyzeSEO panel, generateSEOMetadata (title/meta/OG/Twitter/schema), apply fixes
|
||||
|
||||
Milestone 4: Publishing
|
||||
- prepareForPublish, publishToPlatform (Wix/WordPress), schedule, success URL
|
||||
|
||||
Milestone 5: Polish
|
||||
- Readability aids, version history, performance, accessibility
|
||||
|
||||
---
|
||||
|
||||
## 8) References
|
||||
- CopilotKit Quickstart, Frontend Actions, Generative UI, HITL, Suggestions
|
||||
- Quickstart: https://docs.copilotkit.ai/direct-to-llm/guides/quickstart
|
||||
- Frontend Actions: https://docs.copilotkit.ai/frontend-actions
|
||||
- Generative UI: https://docs.copilotkit.ai/direct-to-llm/guides/generative-ui
|
||||
- Headless + Suggestions + HITL: https://docs.copilotkit.ai/premium/headless-ui
|
||||
|
||||
---
|
||||
|
||||
## 9) Notes on Reuse from LinkedIn Writer
|
||||
- Research handler; Gemini grounded provider; citation manager; quality analyzer.
|
||||
- Hallucination detector + Exa verification endpoints.
|
||||
- CopilotKit integration patterns: actions, suggestions, render/HITL, state persistence.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import SEODashboard from './components/SEODashboard/SEODashboard';
|
||||
import ContentPlanningDashboard from './components/ContentPlanningDashboard/ContentPlanningDashboard';
|
||||
import FacebookWriter from './components/FacebookWriter/FacebookWriter';
|
||||
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
||||
import BlogWriter from './components/BlogWriter/BlogWriter';
|
||||
|
||||
import { apiClient } from './api/client';
|
||||
|
||||
@@ -187,6 +188,7 @@ const App: React.FC = () => {
|
||||
<Route path="/content-planning" element={<ContentPlanningDashboard />} />
|
||||
<Route path="/facebook-writer" element={<FacebookWriter />} />
|
||||
<Route path="/linkedin-writer" element={<LinkedInWriter />} />
|
||||
<Route path="/blog-writer" element={<BlogWriter />} />
|
||||
</Routes>
|
||||
</ConditionalCopilotKit>
|
||||
</Router>
|
||||
|
||||
581
frontend/src/components/BlogWriter/BlogWriter.tsx
Normal file
581
frontend/src/components/BlogWriter/BlogWriter.tsx
Normal file
@@ -0,0 +1,581 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
import { blogWriterApi, BlogOutlineSection, BlogResearchResponse, BlogSEOMetadataResponse, BlogSEOAnalyzeResponse } from '../../services/blogWriterApi';
|
||||
import EnhancedOutlineEditor from './EnhancedOutlineEditor';
|
||||
import TitleSelector from './TitleSelector';
|
||||
import DiffPreview from './DiffPreview';
|
||||
import SEOMiniPanel from './SEOMiniPanel';
|
||||
import ResearchResults from './ResearchResults';
|
||||
import KeywordInputForm from './KeywordInputForm';
|
||||
import ResearchAction from './ResearchAction';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const BlogWriter: React.FC = () => {
|
||||
const [research, setResearch] = useState<BlogResearchResponse | null>(null);
|
||||
const [outline, setOutline] = useState<BlogOutlineSection[]>([]);
|
||||
const [titleOptions, setTitleOptions] = useState<string[]>([]);
|
||||
const [selectedTitle, setSelectedTitle] = useState<string>('');
|
||||
const [sections, setSections] = useState<Record<string, string>>({});
|
||||
const [seoAnalysis, setSeoAnalysis] = useState<BlogSEOAnalyzeResponse | null>(null);
|
||||
const [seoMetadata, setSeoMetadata] = useState<BlogSEOMetadataResponse | null>(null);
|
||||
const [hallucinationResult, setHallucinationResult] = useState<any>(null);
|
||||
|
||||
const buildFullMarkdown = () => {
|
||||
if (!outline.length) return '';
|
||||
return outline.map(s => `## ${s.heading}\n\n${sections[s.id] || ''}`).join('\n\n');
|
||||
};
|
||||
|
||||
// Sentence-level claim mapping and patching helpers
|
||||
const normalized = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
|
||||
const fuzzyScore = (a: string, b: string) => {
|
||||
// Dice's coefficient over word bigrams for robustness (no deps)
|
||||
const bigrams = (s: string) => {
|
||||
const t = s.split(/\W+/).filter(Boolean);
|
||||
const grams: string[] = [];
|
||||
for (let i = 0; i < t.length - 1; i++) grams.push(`${t[i]} ${t[i+1]}`);
|
||||
return grams;
|
||||
};
|
||||
const A = new Set(bigrams(a));
|
||||
const B = new Set(bigrams(b));
|
||||
if (!A.size || !B.size) return 0;
|
||||
let overlap = 0;
|
||||
A.forEach(g => { if (B.has(g)) overlap++; });
|
||||
return (2 * overlap) / (A.size + B.size);
|
||||
};
|
||||
|
||||
const findSentenceForClaim = (md: string, claimText: string) => {
|
||||
const text = md || '';
|
||||
// Split by sentence enders; keep delimiters
|
||||
const sentences = text.split(/(?<=[.!?])\s+/);
|
||||
const normalizedClaim = claimText.trim().toLowerCase();
|
||||
// Direct includes first
|
||||
let bestIndex = sentences.findIndex(s => s.toLowerCase().includes(normalizedClaim));
|
||||
if (bestIndex >= 0) return { sentence: sentences[bestIndex], index: bestIndex, sentences };
|
||||
// Fallback: overlap ratio by words
|
||||
const claimWords = normalizedClaim.split(/\W+/).filter(Boolean);
|
||||
let bestScore = 0; bestIndex = -1;
|
||||
sentences.forEach((s, i) => {
|
||||
const sw = s.toLowerCase().split(/\W+/).filter(Boolean);
|
||||
const overlap = claimWords.filter(w => sw.includes(w)).length;
|
||||
const score = overlap / Math.max(claimWords.length, 1);
|
||||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||
});
|
||||
// Second fallback: Dice coefficient on normalized strings
|
||||
if (bestIndex < 0) {
|
||||
let diceBest = 0; let diceIdx = -1;
|
||||
sentences.forEach((s, i) => {
|
||||
const sc = fuzzyScore(normalized(s), normalized(claimText));
|
||||
if (sc > diceBest) { diceBest = sc; diceIdx = i; }
|
||||
});
|
||||
if (diceIdx >= 0) return { sentence: sentences[diceIdx], index: diceIdx, sentences };
|
||||
}
|
||||
if (bestIndex >= 0) return { sentence: sentences[bestIndex], index: bestIndex, sentences };
|
||||
return { sentence: '', index: -1, sentences };
|
||||
};
|
||||
|
||||
const buildUpdatedMarkdownForClaim = (claimText: string, supportingUrl?: string) => {
|
||||
const md = buildFullMarkdown();
|
||||
const { sentence, index, sentences } = findSentenceForClaim(md, claimText);
|
||||
if (!sentence || index < 0) return { original: '', updated: '', updatedMarkdown: md };
|
||||
const alreadyHasLink = /\[[^\]]+\]\(([^)]+)\)/.test(sentence);
|
||||
const fix = supportingUrl && !alreadyHasLink ? `${sentence} [source](${supportingUrl})` : sentence;
|
||||
const updatedSentences = [...sentences];
|
||||
updatedSentences[index] = fix;
|
||||
const updatedMarkdown = updatedSentences.join(' ');
|
||||
return { original: sentence, updated: fix, updatedMarkdown };
|
||||
};
|
||||
|
||||
const applyClaimFix = (claimText: string, supportingUrl?: string) => {
|
||||
// Naive fix: append citation footnote to the first occurrence of claim text
|
||||
const { updatedMarkdown } = buildUpdatedMarkdownForClaim(claimText, supportingUrl);
|
||||
const updated = updatedMarkdown;
|
||||
// Re-split content back to per-section, by headings
|
||||
const parts = updated.split(/^## /gm).filter(Boolean);
|
||||
const newSections: Record<string, string> = {};
|
||||
outline.forEach((s, idx) => {
|
||||
const body = parts[idx] ? parts[idx].replace(new RegExp(`^${s.heading}\n\n?`), '') : (sections[s.id] || '');
|
||||
newSections[s.id] = body;
|
||||
});
|
||||
setSections(newSections);
|
||||
};
|
||||
|
||||
// Handle research completion
|
||||
const handleResearchComplete = (researchData: BlogResearchResponse) => {
|
||||
setResearch(researchData);
|
||||
};
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateOutline',
|
||||
description: 'Generate outline from research results using AI analysis',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
if (!research) return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
|
||||
try {
|
||||
const res = await blogWriterApi.generateOutline({ research });
|
||||
if (res?.outline) {
|
||||
setOutline(res.outline);
|
||||
setTitleOptions(res.title_options || []);
|
||||
if (res.title_options && res.title_options.length > 0) {
|
||||
setSelectedTitle(res.title_options[0]); // Auto-select first title
|
||||
}
|
||||
|
||||
const outlineCount = res.outline.length;
|
||||
const primaryKeywords = research.keyword_analysis?.primary || [];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🧩 Outline generated successfully! Created ${outlineCount} sections based on your research. The outline incorporates your primary keywords (${primaryKeywords.join(', ')}) and follows the content angles we discovered. You can now review the outline structure, choose a title, and generate content for individual sections.`,
|
||||
outline_summary: {
|
||||
sections: outlineCount,
|
||||
primary_keywords: primaryKeywords,
|
||||
research_sources: research.sources?.length || 0,
|
||||
title_options: res.title_options?.length || 0
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Outline generation failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Outline generation failed: ${errorMessage}. The AI system encountered an issue while creating your outline. Please try again or contact support if the problem persists.`
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to generate outline. The AI outline generation system encountered an issue. Please try again or contact support if the problem persists.'
|
||||
};
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
console.log('generateOutline render called with status:', status);
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #388e3c',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#388e3c' }}>🧩 Generating Outline</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing research results and content angles...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Structuring content based on keyword analysis...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Creating logical flow and section hierarchy...</p>
|
||||
<p style={{ margin: '0' }}>• Optimizing for SEO and reader engagement...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSection',
|
||||
description: 'Generate content for a specific section using research and outline',
|
||||
parameters: [ { name: 'sectionId', type: 'string', description: 'Section ID', required: true } ],
|
||||
handler: async ({ sectionId }: { sectionId: string }) => {
|
||||
const section = outline.find(s => s.id === sectionId);
|
||||
if (!section) return { success: false, message: 'Section not found. Please generate an outline first.' };
|
||||
|
||||
try {
|
||||
const res = await blogWriterApi.generateSection({ section });
|
||||
if (res?.markdown) {
|
||||
setSections(prev => ({ ...prev, [sectionId]: res.markdown }));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✍️ Content generated for "${section.heading}"! The section incorporates your research findings and primary keywords. You can now review the content, run SEO analysis, or generate more sections.`,
|
||||
section_summary: {
|
||||
heading: section.heading,
|
||||
content_length: res.markdown.length,
|
||||
primary_keywords: research?.keyword_analysis?.primary || []
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Section generation failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Content generation failed for "${section.heading}": ${errorMessage}. Please try again or contact support if the problem persists.`
|
||||
};
|
||||
}
|
||||
return { success: false, message: 'Failed to generate section content. Please try again.' };
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #f57c00',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#f57c00' }}>✍️ Generating Section Content</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing section requirements and research data...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Incorporating primary keywords and SEO best practices...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Writing engaging content with proper structure...</p>
|
||||
<p style={{ margin: '0' }}>• Ensuring factual accuracy and readability...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateAllSections',
|
||||
description: 'Generate content for every section in the outline',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
for (const s of outline) {
|
||||
const res = await blogWriterApi.generateSection({ section: s });
|
||||
setSections(prev => ({ ...prev, [s.id]: res.markdown }));
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
render: ({ status }: any) => (status === 'inProgress' || status === 'executing') ? <div>Generating all sections…</div> : null
|
||||
});
|
||||
|
||||
// Outline refinement (basic op pass-through)
|
||||
useCopilotActionTyped({
|
||||
name: 'refineOutline',
|
||||
description: 'Refine the outline (add/remove/move/merge)',
|
||||
parameters: [
|
||||
{ name: 'operation', type: 'string', description: 'add|remove|move|merge|rename', required: true },
|
||||
{ name: 'sectionId', type: 'string', description: 'Target section ID', required: false },
|
||||
{ name: 'payload', type: 'string', description: 'JSON payload for operation', required: false },
|
||||
],
|
||||
handler: async ({ operation, sectionId, payload }: { operation: string; sectionId?: string; payload?: string }) => {
|
||||
const payloadObj = payload ? (() => { try { return JSON.parse(payload); } catch { return {}; } })() : undefined;
|
||||
const res = await blogWriterApi.refineOutline({ outline, operation, section_id: sectionId, payload: payloadObj });
|
||||
if (res?.outline) setOutline(res.outline);
|
||||
return { success: true };
|
||||
}
|
||||
});
|
||||
|
||||
// Optimize section with HITL diff preview
|
||||
useCopilotActionTyped({
|
||||
name: 'optimizeSection',
|
||||
description: 'Optimize a section for readability/EEAT/examples/data with HITL diff',
|
||||
parameters: [
|
||||
{ name: 'sectionId', type: 'string', description: 'Section ID', required: true },
|
||||
{ name: 'goals', type: 'string', description: 'Comma-separated goals', required: false },
|
||||
],
|
||||
handler: async ({ sectionId, goals }: { sectionId: string; goals?: string }) => {
|
||||
const current = sections[sectionId] || '';
|
||||
if (!current) return { success: false, message: 'No content yet for this section' };
|
||||
const res = await blogWriterApi.seoAnalyze({ content: current, keywords: [] });
|
||||
setSeoAnalysis(res);
|
||||
return { success: true, message: 'Analysis ready' };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond, args, status }: any) => {
|
||||
if (status === 'complete') return <div>Optimization applied.</div>;
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Optimization preview</div>
|
||||
<div style={{ marginBottom: 8 }}>Goals: {args.goals || 'readability, EEAT'}</div>
|
||||
<button onClick={() => respond?.('apply')}>Apply Changes</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// SEO analyze full draft
|
||||
useCopilotActionTyped({
|
||||
name: 'runSEOAnalyze',
|
||||
description: 'Analyze SEO for the full draft',
|
||||
parameters: [ { name: 'keywords', type: 'string', description: 'Comma-separated keywords', required: false } ],
|
||||
handler: async ({ keywords }: { keywords?: string }) => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await blogWriterApi.seoAnalyze({ content, keywords: keywords ? keywords.split(',').map(k => k.trim()) : [] });
|
||||
setSeoAnalysis(res);
|
||||
return { success: true, seo_score: res.seo_score };
|
||||
},
|
||||
render: ({ status, result }: any) => status === 'complete' ? (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div>SEO Score: {result?.seo_score ?? '—'}</div>
|
||||
</div>
|
||||
) : null
|
||||
});
|
||||
|
||||
// SEO metadata generate + HITL accept
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSEOMetadata',
|
||||
description: 'Generate SEO metadata for the full draft',
|
||||
parameters: [ { name: 'title', type: 'string', description: 'Preferred title', required: false } ],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await blogWriterApi.seoMetadata({ content, title, keywords: [] });
|
||||
setSeoMetadata(res);
|
||||
return { success: true };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond }: any) => (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>SEO Metadata Ready</div>
|
||||
<div style={{ marginBottom: 8 }}>Review the generated title, meta description, and OG/Twitter tags in the editor.</div>
|
||||
<button onClick={() => respond?.('accept')}>Accept Metadata</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
|
||||
// Hallucination check with HITL apply-fix
|
||||
useCopilotActionTyped({
|
||||
name: 'runHallucinationCheck',
|
||||
description: 'Run hallucination detector on full draft and view claims',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await fetch('/api/blog/quality/hallucination-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content })
|
||||
});
|
||||
const data = await res.json();
|
||||
setHallucinationResult(data);
|
||||
return { success: true, total_claims: data?.total_claims };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond, result }: any) => {
|
||||
if (!result) return null;
|
||||
const claims = hallucinationResult?.claims || [];
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Hallucination Check</div>
|
||||
<div>Total claims: {hallucinationResult?.total_claims ?? 0}</div>
|
||||
<ul>
|
||||
{claims.slice(0, 5).map((c: any, i: number) => {
|
||||
const supporting = (c.supporting_sources && c.supporting_sources[0]?.url) || undefined;
|
||||
const { original, updated } = buildUpdatedMarkdownForClaim(c.text, supporting);
|
||||
return (
|
||||
<li key={i} style={{ marginBottom: 10 }}>
|
||||
<div style={{ marginBottom: 4 }}>[{c.assessment}] {c.text} (conf: {Math.round((c.confidence || 0)*100)/100})</div>
|
||||
{original && updated ? (
|
||||
<DiffPreview
|
||||
original={original}
|
||||
updated={updated}
|
||||
onApply={() => { applyClaimFix(c.text, supporting); respond?.('applied'); }}
|
||||
onDiscard={() => { respond?.('discarded'); }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontStyle: 'italic', color: '#666' }}>No matching sentence found for preview.</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button onClick={() => respond?.('ack')}>Close</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Publish (convert markdown -> HTML rudimentary; TODO: replace with proper converter like marked)
|
||||
useCopilotActionTyped({
|
||||
name: 'publishToPlatform',
|
||||
description: 'Publish the blog to Wix or WordPress',
|
||||
parameters: [
|
||||
{ name: 'platform', type: 'string', description: 'wix|wordpress', required: true },
|
||||
{ name: 'schedule_time', type: 'string', description: 'Optional ISO datetime', required: false }
|
||||
],
|
||||
handler: async ({ platform, schedule_time }: { platform: 'wix' | 'wordpress'; schedule_time?: string }) => {
|
||||
const md = buildFullMarkdown();
|
||||
const html = md
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
||||
.replace(/\n\n/g, '<br/><br/>');
|
||||
if (!seoMetadata) return { success: false, message: 'Generate SEO metadata first' };
|
||||
const res = await blogWriterApi.publish({ platform, html, metadata: seoMetadata, schedule_time });
|
||||
return { success: true, url: res.url };
|
||||
},
|
||||
render: ({ status, result }: any) => status === 'complete' ? (
|
||||
<div style={{ padding: 12 }}>Published: {result?.url || 'Success'}</div>
|
||||
) : null
|
||||
});
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const items = [] as { title: string; message: string }[];
|
||||
if (!research) items.push({ title: '🔎 Start research', message: "I want to research a topic for my blog" });
|
||||
if (research && outline.length === 0) items.push({ title: '🧩 Create Outline', message: 'Let\'s proceed to create an outline based on the research results' });
|
||||
if (outline.length > 0) {
|
||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||
outline.forEach(s => items.push({ title: `✍️ Generate ${s.heading}`, message: `Generate the section: ${s.heading}` }));
|
||||
items.push({ title: '🔧 Refine outline', message: 'Help me refine the outline structure' });
|
||||
items.push({ title: '📈 Run SEO analysis', message: 'Analyze SEO for my blog post' });
|
||||
items.push({ title: '🧾 Generate SEO metadata', message: 'Generate SEO metadata and title' });
|
||||
items.push({ title: '🧪 Hallucination check', message: 'Check for any false claims in my content' });
|
||||
items.push({ title: '🚀 Publish to WordPress', message: 'Publish my blog to WordPress' });
|
||||
}
|
||||
return items;
|
||||
}, [research, outline]);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Extracted Components */}
|
||||
<KeywordInputForm onResearchComplete={handleResearchComplete} />
|
||||
<ResearchAction onResearchComplete={handleResearchComplete} />
|
||||
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
|
||||
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
|
||||
{!research && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
color: '#666',
|
||||
fontSize: '16px'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔍</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#333' }}>Ready to Research Your Blog Topic</h3>
|
||||
<p style={{ margin: 0 }}>Start by asking the copilot to research your topic.</p>
|
||||
</div>
|
||||
)}
|
||||
{research && outline.length === 0 && <ResearchResults research={research} />}
|
||||
{outline.length > 0 && (
|
||||
<div>
|
||||
{/* Title Selection */}
|
||||
{titleOptions.length > 0 && (
|
||||
<TitleSelector
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle}
|
||||
onTitleSelect={setSelectedTitle}
|
||||
onCustomTitle={(title) => {
|
||||
setTitleOptions(prev => [...prev, title]);
|
||||
setSelectedTitle(title);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Enhanced Outline Editor */}
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then(res => setOutline(res.outline))}
|
||||
/>
|
||||
|
||||
{outline.map(s => (
|
||||
<div key={s.id} style={{ marginBottom: 16 }}>
|
||||
<h4>{s.heading}</h4>
|
||||
{sections[s.id] ? (
|
||||
<>
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
|
||||
<SEOMiniPanel analysis={seoAnalysis} />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CopilotSidebar
|
||||
labels={{ title: 'ALwrity Co-Pilot', initial: 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!' }}
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
// Get current state information
|
||||
const hasResearch = research !== null;
|
||||
const hasOutline = outline.length > 0;
|
||||
const researchInfo = hasResearch ? {
|
||||
sources: research.sources?.length || 0,
|
||||
queries: research.search_queries?.length || 0,
|
||||
angles: research.suggested_angles?.length || 0,
|
||||
primaryKeywords: research.keyword_analysis?.primary || [],
|
||||
searchIntent: research.keyword_analysis?.search_intent || 'informational'
|
||||
} : null;
|
||||
|
||||
const toolGuide = `
|
||||
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
|
||||
|
||||
CURRENT STATE:
|
||||
${hasResearch && researchInfo ? `
|
||||
✅ RESEARCH COMPLETED:
|
||||
- Found ${researchInfo.sources} sources with Google Search grounding
|
||||
- Generated ${researchInfo.queries} search queries
|
||||
- Created ${researchInfo.angles} content angles
|
||||
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
|
||||
- Search intent: ${researchInfo.searchIntent}
|
||||
` : '❌ No research completed yet'}
|
||||
|
||||
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created` : '❌ No outline generated yet'}
|
||||
|
||||
Available tools:
|
||||
- getResearchKeywords(prompt?: string) - Get keywords from user for research
|
||||
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
|
||||
- researchTopic(keywords: string, industry?: string, target_audience?: string)
|
||||
- generateOutline()
|
||||
- generateSection(sectionId: string)
|
||||
- generateAllSections()
|
||||
- refineOutline(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
|
||||
- runSEOAnalyze(keywords?: string)
|
||||
- generateSEOMetadata(title?: string)
|
||||
- runHallucinationCheck()
|
||||
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
|
||||
|
||||
CRITICAL BEHAVIOR:
|
||||
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
|
||||
- When user asks to research something, call getResearchKeywords() first to collect their keywords
|
||||
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
|
||||
- When user asks for outline, call generateOutline()
|
||||
- When user asks to generate content, call generateSection or generateAllSections
|
||||
- DO NOT ask for clarification - take action immediately with the information provided
|
||||
- Always call the appropriate tool instead of just talking about what you could do
|
||||
- Be aware of the current state and reference research results when relevant
|
||||
- Guide users through the process: Research → Outline → Content → SEO → Publish
|
||||
`;
|
||||
return [toolGuide, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriter;
|
||||
51
frontend/src/components/BlogWriter/DiffPreview.tsx
Normal file
51
frontend/src/components/BlogWriter/DiffPreview.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
original: string;
|
||||
updated: string;
|
||||
onApply: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
function highlightDiff(a: string, b: string) {
|
||||
// Simple common prefix/suffix highlighting
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
||||
let j = 0;
|
||||
while (j < a.length - i && j < b.length - i && a[a.length - 1 - j] === b[b.length - 1 - j]) j++;
|
||||
const aMid = a.substring(i, a.length - j);
|
||||
const bMid = b.substring(i, b.length - j);
|
||||
const aHtml = `${escapeHtml(a.substring(0, i))}<span style="background:#ffe5e5;text-decoration:line-through;">${escapeHtml(aMid)}</span>${escapeHtml(a.substring(a.length - j))}`;
|
||||
const bHtml = `${escapeHtml(b.substring(0, i))}<span style="background:#e6ffed;">${escapeHtml(bMid)}</span>${escapeHtml(b.substring(b.length - j))}`;
|
||||
return { aHtml, bHtml };
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const DiffPreview: React.FC<Props> = ({ original, updated, onApply, onDiscard }) => {
|
||||
const { aHtml, bHtml } = highlightDiff(original, updated);
|
||||
return (
|
||||
<div style={{ border: '1px solid #ddd', padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Preview Changes</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, background: '#fafafa', padding: 8 }} dangerouslySetInnerHTML={{ __html: aHtml }} />
|
||||
<div style={{ flex: 1, background: '#f5fff5', padding: 8 }} dangerouslySetInnerHTML={{ __html: bHtml }} />
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<button onClick={onApply}>Apply</button>
|
||||
<button onClick={onDiscard}>Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffPreview;
|
||||
|
||||
|
||||
535
frontend/src/components/BlogWriter/EnhancedOutlineEditor.tsx
Normal file
535
frontend/src/components/BlogWriter/EnhancedOutlineEditor.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
interface Props {
|
||||
outline: BlogOutlineSection[];
|
||||
onRefine: (operation: string, sectionId?: string, payload?: any) => void;
|
||||
research?: any; // Research data for context
|
||||
}
|
||||
|
||||
const EnhancedOutlineEditor: React.FC<Props> = ({ outline, onRefine, research }) => {
|
||||
const [editingSection, setEditingSection] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
const [showAddSection, setShowAddSection] = useState(false);
|
||||
const [newSectionData, setNewSectionData] = useState({
|
||||
heading: '',
|
||||
subheadings: '',
|
||||
key_points: '',
|
||||
target_words: 300
|
||||
});
|
||||
|
||||
const toggleExpanded = (sectionId: string) => {
|
||||
const newExpanded = new Set(expandedSections);
|
||||
if (newExpanded.has(sectionId)) {
|
||||
newExpanded.delete(sectionId);
|
||||
} else {
|
||||
newExpanded.add(sectionId);
|
||||
}
|
||||
setExpandedSections(newExpanded);
|
||||
};
|
||||
|
||||
const handleRename = (sectionId: string, newHeading: string) => {
|
||||
if (newHeading.trim()) {
|
||||
onRefine('rename', sectionId, { heading: newHeading.trim() });
|
||||
}
|
||||
setEditingSection(null);
|
||||
};
|
||||
|
||||
const handleMove = (sectionId: string, direction: 'up' | 'down') => {
|
||||
onRefine('move', sectionId, { direction });
|
||||
};
|
||||
|
||||
const handleAddSection = () => {
|
||||
if (newSectionData.heading.trim()) {
|
||||
const subheadings = newSectionData.subheadings
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
const keyPoints = newSectionData.key_points
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
onRefine('add', undefined, {
|
||||
heading: newSectionData.heading.trim(),
|
||||
subheadings,
|
||||
key_points: keyPoints,
|
||||
target_words: newSectionData.target_words
|
||||
});
|
||||
|
||||
setNewSectionData({
|
||||
heading: '',
|
||||
subheadings: '',
|
||||
key_points: '',
|
||||
target_words: 300
|
||||
});
|
||||
setShowAddSection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalWords = () => {
|
||||
return outline.reduce((total, section) => total + (section.target_words || 0), 0);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: '#333', fontSize: '20px' }}>
|
||||
📋 Blog Outline
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#666', fontSize: '14px' }}>
|
||||
{outline.length} sections • {getTotalWords()} words total
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddSection(!showAddSection)}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
➕ Add Section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Section Form */}
|
||||
{showAddSection && (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>Add New Section</h3>
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Section Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSectionData.heading}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, heading: e.target.value})}
|
||||
placeholder="Enter section title..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Subheadings (one per line)
|
||||
</label>
|
||||
<textarea
|
||||
value={newSectionData.subheadings}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, subheadings: e.target.value})}
|
||||
placeholder="Subheading 1 Subheading 2 Subheading 3"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Key Points (one per line)
|
||||
</label>
|
||||
<textarea
|
||||
value={newSectionData.key_points}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, key_points: e.target.value})}
|
||||
placeholder="Key point 1 Key point 2 Key point 3"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Target Words
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newSectionData.target_words}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, target_words: parseInt(e.target.value) || 300})}
|
||||
min="100"
|
||||
max="2000"
|
||||
style={{
|
||||
width: '120px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleAddSection}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Add Section
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddSection(false)}
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outline Sections */}
|
||||
<div style={{ padding: '0' }}>
|
||||
{outline.map((section, index) => (
|
||||
<div key={section.id} style={{
|
||||
borderBottom: index < outline.length - 1 ? '1px solid #f0f0f0' : 'none',
|
||||
transition: 'all 0.2s ease'
|
||||
}}>
|
||||
{/* Section Header */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: expandedSections.has(section.id) ? '#f8f9fa' : 'white',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
onClick={() => toggleExpanded(section.id)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||
<div style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{editingSection === section.id ? (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={section.heading}
|
||||
onBlur={(e) => handleRename(section.id, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRename(section.id, e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
border: '1px solid #1976d2',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
flex: 1
|
||||
}}>
|
||||
{section.heading}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{section.target_words || 300} words
|
||||
</span>
|
||||
|
||||
{section.references && section.references.length > 0 && (
|
||||
<span style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{section.references.length} sources
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSection(section.id);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMove(section.id, 'up');
|
||||
}}
|
||||
disabled={index === 0}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: index === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
color: index === 0 ? '#ccc' : '#666',
|
||||
opacity: index === 0 ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
⬆️
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMove(section.id, 'down');
|
||||
}}
|
||||
disabled={index === outline.length - 1}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: index === outline.length - 1 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
color: index === outline.length - 1 ? '#ccc' : '#666',
|
||||
opacity: index === outline.length - 1 ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
⬇️
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm(`Are you sure you want to remove "${section.heading}"?`)) {
|
||||
onRefine('remove', section.id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #f44336',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#f44336'
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
transform: expandedSections.has(section.id) ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}>
|
||||
▼
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Section Content */}
|
||||
{expandedSections.has(section.id) && (
|
||||
<div style={{ padding: '0 20px 20px 52px', backgroundColor: '#fafafa' }}>
|
||||
{/* Subheadings */}
|
||||
{section.subheadings && section.subheadings.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
📝 Subheadings
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{section.subheadings.map((subheading, i) => (
|
||||
<li key={i} style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
{subheading}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key Points */}
|
||||
{section.key_points && section.key_points.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🎯 Key Points
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{section.key_points.map((point, i) => (
|
||||
<li key={i} style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keywords */}
|
||||
{section.keywords && section.keywords.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🎯 SEO Keywords
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{section.keywords.map((keyword, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* References */}
|
||||
{section.references && section.references.length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
📚 Sources ({section.references.length})
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{section.references.map((ref, i) => (
|
||||
<div key={i} style={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
maxWidth: '200px'
|
||||
}}>
|
||||
<div style={{ fontWeight: '500', marginBottom: '2px' }}>
|
||||
{ref.title}
|
||||
</div>
|
||||
<div style={{ color: '#999' }}>
|
||||
Credibility: {Math.round((ref.credibility_score || 0.8) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
💡 Tip: Click on any section to expand and see details. Use the controls to reorder, edit, or remove sections.
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Total: {getTotalWords()} words
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedOutlineEditor;
|
||||
292
frontend/src/components/BlogWriter/KeywordInputForm.tsx
Normal file
292
frontend/src/components/BlogWriter/KeywordInputForm.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface KeywordInputFormProps {
|
||||
onKeywordsReceived?: (data: { keywords: string; blogLength: string }) => void;
|
||||
onResearchComplete?: (researchData: BlogResearchResponse) => void;
|
||||
}
|
||||
|
||||
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete }) => {
|
||||
// State for button enable/disable only
|
||||
const [hasInput, setHasInput] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const selectRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
// Focus input when form appears
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
// Keyword input action with Human-in-the-Loop
|
||||
useCopilotActionTyped({
|
||||
name: 'getResearchKeywords',
|
||||
description: 'Get keywords from user for blog research',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
|
||||
if (status === 'complete') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
|
||||
✅ Research keywords received! Starting research...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
key="keyword-input-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
|
||||
🔍 Let's Research Your Blog Topic
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
{args.prompt || 'Please provide the keywords or topic you want to research for your blog:'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Keywords or Topic *
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
// Update state for button enable/disable
|
||||
setHasInput(value.trim().length > 0);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.select();
|
||||
}}
|
||||
placeholder="e.g., artificial intelligence, machine learning, AI trends"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Blog Length (words)
|
||||
</label>
|
||||
<select
|
||||
ref={selectRef}
|
||||
defaultValue="1000"
|
||||
onChange={(e) => {
|
||||
// No state update needed for select
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<option value="500">500 words (Short blog)</option>
|
||||
<option value="1000">1000 words (Medium blog)</option>
|
||||
<option value="1500">1500 words (Long blog)</option>
|
||||
<option value="2000">2000+ words (Comprehensive guide)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const kw = (inputRef.current?.value || '').trim();
|
||||
const len = (selectRef.current?.value || '1000');
|
||||
if (kw) {
|
||||
const formData = {
|
||||
keywords: kw,
|
||||
blogLength: len
|
||||
};
|
||||
|
||||
// Notify parent component if callback provided
|
||||
onKeywordsReceived?.(formData);
|
||||
|
||||
// Send to CopilotKit to trigger performResearch action
|
||||
respond?.(JSON.stringify(formData));
|
||||
}
|
||||
}}
|
||||
disabled={!hasInput}
|
||||
style={{
|
||||
backgroundColor: hasInput ? '#1976d2' : '#f5f5f5',
|
||||
color: hasInput ? 'white' : '#999',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: hasInput ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
🚀 Start Research
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
respond?.('CANCEL');
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Research action that actually performs the research
|
||||
useCopilotActionTyped({
|
||||
name: 'performResearch',
|
||||
description: 'Perform research with collected keywords and blog length',
|
||||
parameters: [
|
||||
{ name: 'formData', type: 'string', description: 'JSON string with keywords and blogLength', required: true }
|
||||
],
|
||||
handler: async ({ formData }: { formData: string }) => {
|
||||
try {
|
||||
const data = JSON.parse(formData);
|
||||
const { keywords, blogLength } = data;
|
||||
|
||||
// If keywords is a topic description, extract keywords from it
|
||||
const keywordList = keywords.includes(',')
|
||||
? keywords.split(',').map((k: string) => k.trim())
|
||||
: keywords.split(' ').filter((k: string) => k.length > 2).slice(0, 5);
|
||||
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: 'General',
|
||||
target_audience: 'General',
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
|
||||
const res = await blogWriterApi.research(payload);
|
||||
|
||||
// Notify parent component
|
||||
onResearchComplete?.(res);
|
||||
|
||||
const sourcesCount = res.sources?.length || 0;
|
||||
const queriesCount = res.search_queries?.length || 0;
|
||||
const anglesCount = res.suggested_angles?.length || 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🔍 Research completed for "${keywords}"! Found ${sourcesCount} sources, ${queriesCount} search queries, and ${anglesCount} content angles. The research results are now displayed in the UI.`,
|
||||
research_summary: {
|
||||
topic: keywords,
|
||||
sources: sourcesCount,
|
||||
queries: queriesCount,
|
||||
angles: anglesCount,
|
||||
primary_keywords: res.keyword_analysis?.primary || [],
|
||||
search_intent: res.keyword_analysis?.search_intent || 'informational'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Research failed: ${error}. Please try again with different keywords.`
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
console.log('performResearch render called with status:', status);
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #1976d2',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#1976d2' }}>🔍 Researching Your Topic</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Connecting to Google Search grounding...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing keywords and search intent...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Gathering relevant sources and statistics...</p>
|
||||
<p style={{ margin: '0' }}>• Generating content angles and search queries...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the CopilotKit action, no UI
|
||||
};
|
||||
|
||||
export default KeywordInputForm;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const RegisterBlogWriterActions: React.FC = () => {
|
||||
useCopilotActionTyped({
|
||||
name: 'Generate All Sections of Outline',
|
||||
description: 'Generate content for every section in the current outline',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Frontend-only placeholder; generation handled via individual actions in UI for now
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default RegisterBlogWriterActions;
|
||||
|
||||
|
||||
108
frontend/src/components/BlogWriter/ResearchAction.tsx
Normal file
108
frontend/src/components/BlogWriter/ResearchAction.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface ResearchActionProps {
|
||||
onResearchComplete?: (research: BlogResearchResponse) => void;
|
||||
}
|
||||
|
||||
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete }) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'researchTopic',
|
||||
description: 'Research topic with keywords and persona context using Google Search grounding',
|
||||
parameters: [
|
||||
{ name: 'keywords', type: 'string', description: 'Comma-separated keywords or topic description', required: true },
|
||||
{ name: 'industry', type: 'string', description: 'Industry', required: false },
|
||||
{ name: 'target_audience', type: 'string', description: 'Target audience', required: false },
|
||||
{ name: 'blogLength', type: 'string', description: 'Target blog length in words', required: false }
|
||||
],
|
||||
handler: async ({ keywords, industry, target_audience, blogLength }: { keywords: string; industry?: string; target_audience?: string; blogLength?: string }) => {
|
||||
try {
|
||||
// If keywords is a topic description, extract keywords from it
|
||||
const keywordList = keywords.includes(',')
|
||||
? keywords.split(',').map(k => k.trim())
|
||||
: keywords.split(' ').filter(k => k.length > 2).slice(0, 5); // Extract up to 5 meaningful words
|
||||
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: industry || 'General',
|
||||
target_audience: target_audience || 'General',
|
||||
word_count_target: blogLength ? parseInt(blogLength) : 1000
|
||||
};
|
||||
|
||||
const res = await blogWriterApi.research(payload);
|
||||
|
||||
// Notify parent component
|
||||
onResearchComplete?.(res);
|
||||
|
||||
// Create detailed success message with research insights
|
||||
const sourcesCount = res.sources?.length || 0;
|
||||
const queriesCount = res.search_queries?.length || 0;
|
||||
const anglesCount = res.suggested_angles?.length || 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🔍 Research completed for "${keywords}"! Found ${sourcesCount} sources, ${queriesCount} search queries, and ${anglesCount} content angles. The research results are now displayed in the UI. You can explore the sources, keywords, and content angles to understand the topic better before we create an outline.`,
|
||||
research_summary: {
|
||||
topic: keywords,
|
||||
sources: sourcesCount,
|
||||
queries: queriesCount,
|
||||
angles: anglesCount,
|
||||
primary_keywords: res.keyword_analysis?.primary || [],
|
||||
search_intent: res.keyword_analysis?.search_intent || 'informational'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Research failed: ${error}. The AI research system encountered an issue. Please try again with different keywords or contact support if the problem persists.`
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #1976d2',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#1976d2' }}>🔍 Researching Your Topic</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Connecting to Google Search grounding...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing keywords and search intent...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Gathering relevant sources and statistics...</p>
|
||||
<p style={{ margin: '0' }}>• Generating content angles and search queries...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the CopilotKit action, no UI
|
||||
};
|
||||
|
||||
export default ResearchAction;
|
||||
351
frontend/src/components/BlogWriter/ResearchResults.tsx
Normal file
351
frontend/src/components/BlogWriter/ResearchResults.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface ResearchResultsProps {
|
||||
research: BlogResearchResponse;
|
||||
}
|
||||
|
||||
export const ResearchResults: React.FC<ResearchResultsProps> = ({ research }) => {
|
||||
const [activeTab, setActiveTab] = useState<'sources' | 'keywords' | 'angles' | 'queries'>('sources');
|
||||
const [showSearchWidget, setShowSearchWidget] = useState(false);
|
||||
|
||||
const renderCredibilityScore = (score: number | undefined) => {
|
||||
const safeScore = score ?? 0.8; // Default to 0.8 if undefined
|
||||
const percentage = Math.round(safeScore * 100);
|
||||
const color = safeScore >= 0.8 ? '#4CAF50' : safeScore >= 0.6 ? '#FF9800' : '#F44336';
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
width: '60px',
|
||||
height: '8px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
backgroundColor: color,
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSources = () => (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Research Sources ({research.sources.length})</h3>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{research.sources.map((source, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '600', color: '#333' }}>
|
||||
{source.title}
|
||||
</h4>
|
||||
{renderCredibilityScore(source.credibility_score)}
|
||||
</div>
|
||||
<p style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{source.excerpt}
|
||||
</p>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#1976d2',
|
||||
textDecoration: 'none',
|
||||
wordBreak: 'break-all'
|
||||
}}
|
||||
>
|
||||
{source.url}
|
||||
</a>
|
||||
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
|
||||
Published: {source.published_at}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderKeywordAnalysis = () => (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>🎯 Keyword Analysis</h3>
|
||||
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1976d2' }}>Primary Keywords</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{research.keyword_analysis.primary?.map((keyword: string, index: number) => (
|
||||
<span key={index} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#388e3c' }}>Secondary Keywords</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{research.keyword_analysis.secondary?.map((keyword: string, index: number) => (
|
||||
<span key={index} style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#f57c00' }}>Long-tail Keywords</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{research.keyword_analysis.long_tail?.map((keyword: string, index: number) => (
|
||||
<span key={index} style={{
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#f57c00',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Search Intent</h4>
|
||||
<span style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#333',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{research.keyword_analysis.search_intent || 'Informational'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Difficulty Score</h4>
|
||||
<span style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#333',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{research.keyword_analysis.difficulty || 'N/A'}/10
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderContentAngles = () => (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>💡 Content Angles ({research.suggested_angles.length})</h3>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{research.suggested_angles.map((angle, index) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f0f0f0';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#fafafa';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: '500', color: '#333' }}>
|
||||
{angle}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSearchQueries = () => {
|
||||
const queries = research.search_queries || [];
|
||||
return (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>🔗 Search Queries ({queries.length})</h3>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{queries.map((query: string, index: number) => (
|
||||
<div key={index} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fafafa',
|
||||
fontSize: '13px',
|
||||
color: '#333'
|
||||
}}>
|
||||
<span style={{ color: '#666', marginRight: '8px' }}>{index + 1}.</span>
|
||||
{query}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSearchWidget = () => {
|
||||
if (!research.search_widget) return null;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: 0, color: '#333' }}>🎯 Interactive Search Widget</h3>
|
||||
<button
|
||||
onClick={() => setShowSearchWidget(!showSearchWidget)}
|
||||
style={{
|
||||
backgroundColor: showSearchWidget ? '#1976d2' : '#f5f5f5',
|
||||
color: showSearchWidget ? 'white' : '#333',
|
||||
border: 'none',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{showSearchWidget ? 'Hide Widget' : 'Show Widget'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSearchWidget && (
|
||||
<div style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fafafa',
|
||||
maxHeight: '400px',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<div dangerouslySetInnerHTML={{ __html: research.search_widget }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '16px 0'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, color: '#333', fontSize: '18px' }}>
|
||||
📊 Research Results
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#666', fontSize: '14px' }}>
|
||||
Google Search grounding analysis completed with {research.sources.length} sources and {research.search_queries?.length || 0} search queries
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
{[
|
||||
{ id: 'sources', label: 'Sources', icon: '🔍' },
|
||||
{ id: 'keywords', label: 'Keywords', icon: '🎯' },
|
||||
{ id: 'angles', label: 'Angles', icon: '💡' },
|
||||
{ id: 'queries', label: 'Queries', icon: '🔗' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
backgroundColor: activeTab === tab.id ? 'white' : 'transparent',
|
||||
color: activeTab === tab.id ? '#1976d2' : '#666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: activeTab === tab.id ? '600' : '400',
|
||||
borderBottom: activeTab === tab.id ? '2px solid #1976d2' : '2px solid transparent',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'sources' && renderSources()}
|
||||
{activeTab === 'keywords' && renderKeywordAnalysis()}
|
||||
{activeTab === 'angles' && renderContentAngles()}
|
||||
{activeTab === 'queries' && renderSearchQueries()}
|
||||
|
||||
{/* Search Widget */}
|
||||
{renderSearchWidget()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchResults;
|
||||
25
frontend/src/components/BlogWriter/SEOMiniPanel.tsx
Normal file
25
frontend/src/components/BlogWriter/SEOMiniPanel.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { BlogSEOAnalyzeResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface Props {
|
||||
analysis?: BlogSEOAnalyzeResponse | null;
|
||||
}
|
||||
|
||||
const SEOMiniPanel: React.FC<Props> = ({ analysis }) => {
|
||||
if (!analysis) return null;
|
||||
return (
|
||||
<div style={{ border: '1px solid #eee', padding: 8, marginTop: 8 }}>
|
||||
<div style={{ fontWeight: 600 }}>SEO Mini Panel</div>
|
||||
<div>Score: {analysis.seo_score}</div>
|
||||
{!!analysis.recommendations?.length && (
|
||||
<ul>
|
||||
{analysis.recommendations.slice(0, 3).map((r, i) => (<li key={i}>{r}</li>))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEOMiniPanel;
|
||||
|
||||
|
||||
195
frontend/src/components/BlogWriter/TitleSelector.tsx
Normal file
195
frontend/src/components/BlogWriter/TitleSelector.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface TitleSelectorProps {
|
||||
titleOptions: string[];
|
||||
selectedTitle?: string;
|
||||
onTitleSelect: (title: string) => void;
|
||||
onCustomTitle?: (title: string) => void;
|
||||
}
|
||||
|
||||
const TitleSelector: React.FC<TitleSelectorProps> = ({
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
onTitleSelect,
|
||||
onCustomTitle
|
||||
}) => {
|
||||
const [showCustomInput, setShowCustomInput] = useState(false);
|
||||
const [customTitle, setCustomTitle] = useState('');
|
||||
|
||||
const handleCustomTitleSubmit = () => {
|
||||
if (customTitle.trim() && onCustomTitle) {
|
||||
onCustomTitle(customTitle.trim());
|
||||
setCustomTitle('');
|
||||
setShowCustomInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
padding: '20px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333', fontSize: '18px' }}>
|
||||
📝 Choose Your Blog Title
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 20px 0', color: '#666', fontSize: '14px' }}>
|
||||
Select from AI-generated options or create your own custom title.
|
||||
</p>
|
||||
|
||||
{/* AI-Generated Title Options */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '14px', color: '#333', fontWeight: '600' }}>
|
||||
AI-Generated Options
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{titleOptions.map((title, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => onTitleSelect(title)}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
border: selectedTitle === title ? '2px solid #1976d2' : '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedTitle === title ? '#f0f8ff' : 'white',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '14px',
|
||||
color: '#333'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = '#f8f9fa';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{selectedTitle === title && (
|
||||
<span style={{ color: '#1976d2', fontSize: '16px' }}>✓</span>
|
||||
)}
|
||||
<span style={{ fontWeight: selectedTitle === title ? '600' : '400' }}>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Title Input */}
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '14px', color: '#333', fontWeight: '600' }}>
|
||||
Custom Title
|
||||
</h4>
|
||||
|
||||
{!showCustomInput ? (
|
||||
<button
|
||||
onClick={() => setShowCustomInput(true)}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px dashed #1976d2',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#1976d2',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
✏️ Create Custom Title
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="Enter your custom title..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none'
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCustomTitleSubmit();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleCustomTitleSubmit}
|
||||
disabled={!customTitle.trim()}
|
||||
style={{
|
||||
backgroundColor: customTitle.trim() ? '#1976d2' : '#f5f5f5',
|
||||
color: customTitle.trim() ? 'white' : '#999',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
cursor: customTitle.trim() ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCustomInput(false);
|
||||
setCustomTitle('');
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title Tips */}
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#333', fontWeight: '600' }}>
|
||||
💡 Title Tips
|
||||
</h5>
|
||||
<ul style={{ margin: 0, paddingLeft: '16px', fontSize: '12px', color: '#666' }}>
|
||||
<li>Keep it under 60 characters for better SEO</li>
|
||||
<li>Include your primary keyword naturally</li>
|
||||
<li>Make it compelling and click-worthy</li>
|
||||
<li>Consider your target audience</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleSelector;
|
||||
124
frontend/src/services/blogWriterApi.ts
Normal file
124
frontend/src/services/blogWriterApi.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { apiClient } from "../api/client";
|
||||
|
||||
export interface PersonaInfo {
|
||||
persona_id?: string;
|
||||
tone?: string;
|
||||
audience?: string;
|
||||
industry?: string;
|
||||
}
|
||||
|
||||
export interface ResearchSource {
|
||||
title: string;
|
||||
url: string;
|
||||
excerpt?: string;
|
||||
credibility_score?: number;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
export interface BlogResearchRequest {
|
||||
keywords: string[];
|
||||
topic?: string;
|
||||
industry?: string;
|
||||
target_audience?: string;
|
||||
tone?: string;
|
||||
word_count_target?: number;
|
||||
persona?: PersonaInfo;
|
||||
}
|
||||
|
||||
export interface BlogResearchResponse {
|
||||
success: boolean;
|
||||
sources: ResearchSource[];
|
||||
keyword_analysis: Record<string, any>;
|
||||
competitor_analysis: Record<string, any>;
|
||||
suggested_angles: string[];
|
||||
search_widget?: string;
|
||||
search_queries?: string[];
|
||||
}
|
||||
|
||||
export interface BlogOutlineSection {
|
||||
id: string;
|
||||
heading: string;
|
||||
subheadings: string[];
|
||||
key_points: string[];
|
||||
references: ResearchSource[];
|
||||
target_words?: number;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export interface BlogOutlineResponse {
|
||||
success: boolean;
|
||||
title_options: string[];
|
||||
outline: BlogOutlineSection[];
|
||||
}
|
||||
|
||||
export interface BlogSectionResponse {
|
||||
success: boolean;
|
||||
markdown: string;
|
||||
citations: ResearchSource[];
|
||||
}
|
||||
|
||||
export interface BlogSEOAnalyzeResponse {
|
||||
success: boolean;
|
||||
seo_score: number;
|
||||
density: Record<string, any>;
|
||||
structure: Record<string, any>;
|
||||
readability: Record<string, any>;
|
||||
link_suggestions: any[];
|
||||
image_alt_status: Record<string, any>;
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export interface BlogSEOMetadataResponse {
|
||||
success: boolean;
|
||||
title_options: string[];
|
||||
meta_descriptions: string[];
|
||||
open_graph: Record<string, any>;
|
||||
twitter_card: Record<string, any>;
|
||||
schema: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BlogPublishResponse {
|
||||
success: boolean;
|
||||
platform: string;
|
||||
url?: string;
|
||||
post_id?: string;
|
||||
}
|
||||
|
||||
export const blogWriterApi = {
|
||||
async research(payload: BlogResearchRequest): Promise<BlogResearchResponse> {
|
||||
const { data } = await apiClient.post("/api/blog/research", payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async generateOutline(payload: { research: BlogResearchResponse; persona?: PersonaInfo; word_count?: number }): Promise<BlogOutlineResponse> {
|
||||
const { data } = await apiClient.post("/api/blog/outline/generate", payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async refineOutline(payload: { outline: BlogOutlineSection[]; operation: string; section_id?: string; payload?: any }): Promise<BlogOutlineResponse> {
|
||||
const { data } = await apiClient.post("/api/blog/outline/refine", payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async generateSection(payload: { section: BlogOutlineSection; keywords?: string[]; tone?: string; persona?: PersonaInfo }): Promise<BlogSectionResponse> {
|
||||
const { data } = await apiClient.post("/api/blog/section/generate", payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async seoAnalyze(payload: { content: string; keywords?: string[] }): Promise<BlogSEOAnalyzeResponse> {
|
||||
const { data } = await apiClient.post("/api/blog/seo/analyze", payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async seoMetadata(payload: { content: string; title?: string; keywords?: string[] }): Promise<BlogSEOMetadataResponse> {
|
||||
const { data } = await apiClient.post("/api/blog/seo/metadata", payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async publish(payload: { platform: 'wix' | 'wordpress'; html: string; metadata: BlogSEOMetadataResponse; schedule_time?: string }): Promise<BlogPublishResponse> {
|
||||
const { data } = await apiClient.post("/api/blog/publish", payload);
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
6
test_research.json
Normal file
6
test_research.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"keywords": ["AI content generation", "blog writing"],
|
||||
"topic": "ALwrity content generation",
|
||||
"industry": "Technology",
|
||||
"target_audience": "content creators"
|
||||
}
|
||||
58
test_research.py
Normal file
58
test_research.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Test the research endpoint
|
||||
url = "http://localhost:8000/api/blog/research"
|
||||
payload = {
|
||||
"keywords": ["AI content generation", "blog writing"],
|
||||
"topic": "ALwrity content generation",
|
||||
"industry": "Technology",
|
||||
"target_audience": "content creators"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload)
|
||||
print(f"Status Code: {response.status_code}")
|
||||
print(f"Response Headers: {dict(response.headers)}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print("\n=== RESEARCH RESPONSE ===")
|
||||
print(f"Success: {data.get('success')}")
|
||||
print(f"Sources Count: {len(data.get('sources', []))}")
|
||||
print(f"Search Queries Count: {len(data.get('search_queries', []))}")
|
||||
print(f"Has Search Widget: {bool(data.get('search_widget'))}")
|
||||
print(f"Suggested Angles Count: {len(data.get('suggested_angles', []))}")
|
||||
|
||||
print("\n=== SOURCES ===")
|
||||
for i, source in enumerate(data.get('sources', [])[:3]):
|
||||
print(f"Source {i+1}: {source.get('title', 'No title')}")
|
||||
print(f" URL: {source.get('url', 'No URL')}")
|
||||
print(f" Type: {source.get('type', 'Unknown')}")
|
||||
|
||||
print("\n=== SEARCH QUERIES (First 5) ===")
|
||||
for i, query in enumerate(data.get('search_queries', [])[:5]):
|
||||
print(f"{i+1}. {query}")
|
||||
|
||||
print("\n=== SUGGESTED ANGLES ===")
|
||||
for i, angle in enumerate(data.get('suggested_angles', [])[:3]):
|
||||
print(f"{i+1}. {angle}")
|
||||
|
||||
print("\n=== KEYWORD ANALYSIS ===")
|
||||
kw_analysis = data.get('keyword_analysis', {})
|
||||
print(f"Primary: {kw_analysis.get('primary', [])}")
|
||||
print(f"Secondary: {kw_analysis.get('secondary', [])}")
|
||||
print(f"Search Intent: {kw_analysis.get('search_intent', 'Unknown')}")
|
||||
|
||||
print("\n=== SEARCH WIDGET (First 200 chars) ===")
|
||||
widget = data.get('search_widget', '')
|
||||
if widget:
|
||||
print(widget[:200] + "..." if len(widget) > 200 else widget)
|
||||
else:
|
||||
print("No search widget provided")
|
||||
|
||||
else:
|
||||
print(f"Error: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Request failed: {e}")
|
||||
Reference in New Issue
Block a user