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(user_data_router)
|
||||||
app.include_router(strategy_copilot_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
|
# Include persona router
|
||||||
from api.persona_routes import router as persona_router
|
from api.persona_routes import router as persona_router
|
||||||
app.include_router(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
|
# Initialize the Gemini client with timeout configuration
|
||||||
self.client = genai.Client(api_key=self.api_key)
|
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")
|
logger.info("✅ Gemini Grounded Provider initialized with native Google Search grounding")
|
||||||
|
|
||||||
async def generate_grounded_content(
|
async def generate_grounded_content(
|
||||||
@@ -239,8 +239,8 @@ class GeminiGroundedProvider:
|
|||||||
logger.info(f"Search queries: {grounding_metadata.web_search_queries}")
|
logger.info(f"Search queries: {grounding_metadata.web_search_queries}")
|
||||||
|
|
||||||
# Extract sources from grounding chunks
|
# Extract sources from grounding chunks
|
||||||
|
sources = [] # Initialize sources list
|
||||||
if hasattr(grounding_metadata, 'grounding_chunks') and grounding_metadata.grounding_chunks:
|
if hasattr(grounding_metadata, 'grounding_chunks') and grounding_metadata.grounding_chunks:
|
||||||
sources = []
|
|
||||||
for i, chunk in enumerate(grounding_metadata.grounding_chunks):
|
for i, chunk in enumerate(grounding_metadata.grounding_chunks):
|
||||||
logger.info(f"Chunk {i} attributes: {dir(chunk)}")
|
logger.info(f"Chunk {i} attributes: {dir(chunk)}")
|
||||||
if hasattr(chunk, 'web'):
|
if hasattr(chunk, 'web'):
|
||||||
@@ -251,15 +251,29 @@ class GeminiGroundedProvider:
|
|||||||
'type': 'web'
|
'type': 'web'
|
||||||
}
|
}
|
||||||
sources.append(source)
|
sources.append(source)
|
||||||
result['sources'] = sources
|
logger.info(f"Extracted {len(sources)} sources from grounding chunks")
|
||||||
logger.info(f"Extracted {len(sources)} sources")
|
|
||||||
else:
|
else:
|
||||||
logger.error("❌ CRITICAL: No grounding chunks found in response")
|
logger.warning("⚠️ No grounding chunks found - this is normal for some queries")
|
||||||
logger.error(f"Grounding metadata structure: {dir(grounding_metadata)}")
|
logger.info(f"Grounding metadata available fields: {[attr for attr in dir(grounding_metadata) if not attr.startswith('_')]}")
|
||||||
if hasattr(grounding_metadata, 'grounding_chunks'):
|
|
||||||
logger.error(f"Grounding chunks type: {type(grounding_metadata.grounding_chunks)}")
|
# Check if we have search queries - this means Google Search was triggered
|
||||||
logger.error(f"Grounding chunks value: {grounding_metadata.grounding_chunks}")
|
if hasattr(grounding_metadata, 'web_search_queries') and grounding_metadata.web_search_queries:
|
||||||
raise ValueError("No grounding chunks found - grounding is not working properly")
|
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
|
# Extract citations from grounding supports
|
||||||
if hasattr(grounding_metadata, 'grounding_supports') and grounding_metadata.grounding_supports:
|
if hasattr(grounding_metadata, 'grounding_supports') and grounding_metadata.grounding_supports:
|
||||||
@@ -278,12 +292,37 @@ class GeminiGroundedProvider:
|
|||||||
result['citations'] = citations
|
result['citations'] = citations
|
||||||
logger.info(f"Extracted {len(citations)} citations")
|
logger.info(f"Extracted {len(citations)} citations")
|
||||||
else:
|
else:
|
||||||
logger.error("❌ CRITICAL: No grounding supports found in response")
|
logger.warning("⚠️ No grounding supports found - this is normal when no web sources are retrieved")
|
||||||
logger.error(f"Grounding metadata structure: {dir(grounding_metadata)}")
|
# Create basic citations from the content if we have sources
|
||||||
if hasattr(grounding_metadata, 'grounding_supports'):
|
if sources:
|
||||||
logger.error(f"Grounding supports type: {type(grounding_metadata.grounding_supports)}")
|
citations = []
|
||||||
logger.error(f"Grounding supports value: {grounding_metadata.grounding_supports}")
|
for i, source in enumerate(sources[:3]): # Limit to 3 citations
|
||||||
raise ValueError("No grounding supports found - grounding is not working properly")
|
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"✅ Successfully extracted {len(result['sources'])} sources and {len(result['citations'])} citations from grounding metadata")
|
||||||
logger.info(f"Sources: {result['sources']}")
|
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,
|
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
|
# According to the documentation, we should use response.parsed for structured output
|
||||||
if hasattr(response, 'parsed') and response.parsed is not None:
|
if hasattr(response, 'parsed') and response.parsed is not None:
|
||||||
logger.info("Using response.parsed for structured output")
|
logger.info("Using response.parsed for structured output")
|
||||||
return response.parsed
|
return response.parsed
|
||||||
|
|
||||||
# Fallback to text if parsed is not available
|
logger.error("No valid structured response content found")
|
||||||
if hasattr(response, 'text') and response.text:
|
return {"error": "No valid structured response content found"}
|
||||||
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": ""}
|
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# API key related errors
|
# API key related errors
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ class GoogleSearchService:
|
|||||||
self.base_url = "https://www.googleapis.com/customsearch/v1"
|
self.base_url = "https://www.googleapis.com/customsearch/v1"
|
||||||
|
|
||||||
if not self.api_key or not self.search_engine_id:
|
if not self.api_key or not self.search_engine_id:
|
||||||
logger.warning("Google Search API credentials not configured. Service will use fallback methods.")
|
raise ValueError("Google Search API credentials not configured. Please set GOOGLE_SEARCH_API_KEY and GOOGLE_SEARCH_ENGINE_ID environment variables.")
|
||||||
self.enabled = False
|
|
||||||
else:
|
else:
|
||||||
self.enabled = True
|
self.enabled = True
|
||||||
logger.info("Google Search Service initialized successfully")
|
logger.info("Google Search Service initialized successfully")
|
||||||
@@ -69,8 +68,7 @@ class GoogleSearchService:
|
|||||||
List of search results with credibility scoring
|
List of search results with credibility scoring
|
||||||
"""
|
"""
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
logger.warning("Google Search Service not enabled, using fallback research")
|
raise RuntimeError("Google Search Service is not enabled. Please configure API credentials.")
|
||||||
return await self._fallback_research(topic, industry)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Construct industry-specific search query
|
# Construct industry-specific search query
|
||||||
@@ -99,7 +97,7 @@ class GoogleSearchService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Google search failed: {str(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:
|
def _build_search_query(self, topic: str, industry: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -465,45 +463,6 @@ class GoogleSearchService:
|
|||||||
"statistics": statistics
|
"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]:
|
async def test_api_connection(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -513,11 +472,7 @@ class GoogleSearchService:
|
|||||||
Test results and status information
|
Test results and status information
|
||||||
"""
|
"""
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
return {
|
raise RuntimeError("Google Search Service is not enabled. Please configure API credentials.")
|
||||||
"status": "disabled",
|
|
||||||
"message": "Google Search API credentials not configured",
|
|
||||||
"enabled": False
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Perform a simple test search
|
# Perform a simple test search
|
||||||
|
|||||||
@@ -91,6 +91,37 @@ def setup_monitoring_tables():
|
|||||||
print(" Monitoring will be disabled. Continuing startup...")
|
print(" Monitoring will be disabled. Continuing startup...")
|
||||||
return True # Don't fail startup for monitoring issues
|
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():
|
def setup_monitoring_middleware():
|
||||||
"""Set up monitoring middleware in app.py if not already present."""
|
"""Set up monitoring middleware in app.py if not already present."""
|
||||||
print("🔍 Setting up API monitoring middleware...")
|
print("🔍 Setting up API monitoring middleware...")
|
||||||
@@ -168,7 +199,8 @@ def check_dependencies():
|
|||||||
'openai',
|
'openai',
|
||||||
'google.generativeai',
|
'google.generativeai',
|
||||||
'anthropic',
|
'anthropic',
|
||||||
'mistralai'
|
'mistralai',
|
||||||
|
'sqlalchemy'
|
||||||
]
|
]
|
||||||
|
|
||||||
missing_packages = []
|
missing_packages = []
|
||||||
@@ -212,6 +244,9 @@ def setup_environment():
|
|||||||
setup_monitoring_tables()
|
setup_monitoring_tables()
|
||||||
setup_monitoring_middleware()
|
setup_monitoring_middleware()
|
||||||
|
|
||||||
|
# Set up billing and subscription system
|
||||||
|
setup_billing_tables()
|
||||||
|
|
||||||
print("✅ Environment setup complete")
|
print("✅ Environment setup complete")
|
||||||
|
|
||||||
def verify_persona_tables():
|
def verify_persona_tables():
|
||||||
@@ -238,6 +273,35 @@ def verify_persona_tables():
|
|||||||
print(f"⚠️ Warning: Could not verify persona tables: {e}")
|
print(f"⚠️ Warning: Could not verify persona tables: {e}")
|
||||||
return False
|
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):
|
def start_backend(enable_reload=False):
|
||||||
"""Start the backend server."""
|
"""Start the backend server."""
|
||||||
print("🚀 Starting ALwrity Backend...")
|
print("🚀 Starting ALwrity Backend...")
|
||||||
@@ -276,11 +340,16 @@ def start_backend(enable_reload=False):
|
|||||||
# Verify persona tables exist
|
# Verify persona tables exist
|
||||||
verify_persona_tables()
|
verify_persona_tables()
|
||||||
|
|
||||||
|
# Verify billing tables exist
|
||||||
|
verify_billing_tables()
|
||||||
|
|
||||||
print("\n🌐 Backend is starting...")
|
print("\n🌐 Backend is starting...")
|
||||||
print(" 📖 API Documentation: http://localhost:8000/api/docs")
|
print(" 📖 API Documentation: http://localhost:8000/api/docs")
|
||||||
print(" 🔍 Health Check: http://localhost:8000/health")
|
print(" 🔍 Health Check: http://localhost:8000/health")
|
||||||
print(" 📊 ReDoc: http://localhost:8000/api/redoc")
|
print(" 📊 ReDoc: http://localhost:8000/api/redoc")
|
||||||
print(" 📈 API Monitoring: http://localhost:8000/api/content-planning/monitoring/health")
|
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("\n⏹️ Press Ctrl+C to stop the server")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("\n💡 Usage:")
|
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 ContentPlanningDashboard from './components/ContentPlanningDashboard/ContentPlanningDashboard';
|
||||||
import FacebookWriter from './components/FacebookWriter/FacebookWriter';
|
import FacebookWriter from './components/FacebookWriter/FacebookWriter';
|
||||||
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
||||||
|
import BlogWriter from './components/BlogWriter/BlogWriter';
|
||||||
|
|
||||||
import { apiClient } from './api/client';
|
import { apiClient } from './api/client';
|
||||||
|
|
||||||
@@ -187,6 +188,7 @@ const App: React.FC = () => {
|
|||||||
<Route path="/content-planning" element={<ContentPlanningDashboard />} />
|
<Route path="/content-planning" element={<ContentPlanningDashboard />} />
|
||||||
<Route path="/facebook-writer" element={<FacebookWriter />} />
|
<Route path="/facebook-writer" element={<FacebookWriter />} />
|
||||||
<Route path="/linkedin-writer" element={<LinkedInWriter />} />
|
<Route path="/linkedin-writer" element={<LinkedInWriter />} />
|
||||||
|
<Route path="/blog-writer" element={<BlogWriter />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</ConditionalCopilotKit>
|
</ConditionalCopilotKit>
|
||||||
</Router>
|
</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