Commit
·
d4a4da7
1
Parent(s):
8cab861
refactored the files
Browse files- api/__init__.py +1 -0
- api/routers/__init__.py +34 -0
- api/routers/auth.py +34 -0
- api/routers/correction.py +302 -0
- api/routers/creative.py +154 -0
- api/routers/database.py +171 -0
- api/routers/export.py +48 -0
- api/routers/extensive.py +119 -0
- api/routers/generate.py +196 -0
- api/routers/info.py +73 -0
- api/routers/matrix.py +160 -0
- api/routers/motivator.py +32 -0
- api/routers/trends.py +84 -0
- api/schemas.py +470 -0
- data/frameworks.py +8 -90
- data/glp1.py +1 -0
- frontend/app/generate/page.tsx +3 -1
- frontend/components/generation/GenerationForm.tsx +50 -17
- main.py +0 -0
- scripts/test_trends.py +41 -0
- services/current_occasions.py +91 -0
- services/generator.py +148 -276
- services/generator_prompts.py +204 -0
- services/third_flow.py +92 -15
- services/trend_monitor.py +93 -228
api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API package: schemas and routers
|
api/routers/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API routers - each module handles a logical group of endpoints.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter
|
| 6 |
+
|
| 7 |
+
from .info import router as info_router
|
| 8 |
+
from .auth import router as auth_router
|
| 9 |
+
from .generate import router as generate_router
|
| 10 |
+
from .trends import router as trends_router
|
| 11 |
+
from .correction import router as correction_router
|
| 12 |
+
from .matrix import router as matrix_router
|
| 13 |
+
from .motivator import router as motivator_router
|
| 14 |
+
from .extensive import router as extensive_router
|
| 15 |
+
from .creative import router as creative_router
|
| 16 |
+
from .database import router as database_router
|
| 17 |
+
from .export import router as export_router
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_all_routers() -> list[APIRouter]:
|
| 21 |
+
"""Return all routers in the order they should be registered."""
|
| 22 |
+
return [
|
| 23 |
+
info_router,
|
| 24 |
+
auth_router,
|
| 25 |
+
generate_router,
|
| 26 |
+
trends_router,
|
| 27 |
+
correction_router,
|
| 28 |
+
matrix_router,
|
| 29 |
+
motivator_router,
|
| 30 |
+
extensive_router,
|
| 31 |
+
creative_router,
|
| 32 |
+
database_router,
|
| 33 |
+
export_router,
|
| 34 |
+
]
|
api/routers/auth.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication endpoints."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 4 |
+
|
| 5 |
+
from api.schemas import LoginRequest, LoginResponse
|
| 6 |
+
from services.database import db_service
|
| 7 |
+
from services.auth import auth_service
|
| 8 |
+
|
| 9 |
+
router = APIRouter(tags=["auth"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.post("/auth/login", response_model=LoginResponse)
|
| 13 |
+
async def login(request: LoginRequest):
|
| 14 |
+
"""
|
| 15 |
+
Authenticate a user and return a JWT token.
|
| 16 |
+
Credentials must be created manually using the create_user.py script.
|
| 17 |
+
"""
|
| 18 |
+
user = await db_service.get_user(request.username)
|
| 19 |
+
if not user:
|
| 20 |
+
raise HTTPException(status_code=401, detail="Invalid username or password")
|
| 21 |
+
|
| 22 |
+
hashed_password = user.get("hashed_password")
|
| 23 |
+
if not hashed_password:
|
| 24 |
+
raise HTTPException(status_code=500, detail="User data corrupted")
|
| 25 |
+
|
| 26 |
+
if not auth_service.verify_password(request.password, hashed_password):
|
| 27 |
+
raise HTTPException(status_code=401, detail="Invalid username or password")
|
| 28 |
+
|
| 29 |
+
token = auth_service.create_access_token(request.username)
|
| 30 |
+
return {
|
| 31 |
+
"token": token,
|
| 32 |
+
"username": request.username,
|
| 33 |
+
"message": "Login successful",
|
| 34 |
+
}
|
api/routers/correction.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image correction and regeneration endpoints."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
import random
|
| 7 |
+
import logging
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 10 |
+
|
| 11 |
+
from api.schemas import (
|
| 12 |
+
ImageCorrectRequest,
|
| 13 |
+
ImageCorrectResponse,
|
| 14 |
+
ImageRegenerateRequest,
|
| 15 |
+
ImageRegenerateResponse,
|
| 16 |
+
ImageSelectionRequest,
|
| 17 |
+
)
|
| 18 |
+
from services.correction import correction_service
|
| 19 |
+
from services.database import db_service
|
| 20 |
+
from services.image import image_service
|
| 21 |
+
from services.auth_dependency import get_current_user
|
| 22 |
+
from config import settings
|
| 23 |
+
|
| 24 |
+
router = APIRouter(tags=["correction"])
|
| 25 |
+
api_logger = logging.getLogger("api")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.post("/api/correct", response_model=ImageCorrectResponse)
|
| 29 |
+
async def correct_image(
|
| 30 |
+
request: ImageCorrectRequest,
|
| 31 |
+
username: str = Depends(get_current_user),
|
| 32 |
+
):
|
| 33 |
+
"""
|
| 34 |
+
Correct an image by analyzing it for spelling and visual issues,
|
| 35 |
+
then regenerating a corrected version. Requires authentication.
|
| 36 |
+
"""
|
| 37 |
+
api_start_time = time.time()
|
| 38 |
+
api_logger.info("API: Correction request received | User: %s | Image ID: %s", username, request.image_id)
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
image_url = request.image_url
|
| 42 |
+
ad = None
|
| 43 |
+
if request.image_id != "temp-id":
|
| 44 |
+
ad = await db_service.get_ad_creative(request.image_id, username=username)
|
| 45 |
+
if not ad:
|
| 46 |
+
raise HTTPException(status_code=404, detail=f"Ad creative with ID {request.image_id} not found or access denied")
|
| 47 |
+
if not image_url:
|
| 48 |
+
image_url = ad.get("r2_url") or ad.get("image_url")
|
| 49 |
+
|
| 50 |
+
if not image_url:
|
| 51 |
+
raise HTTPException(status_code=400, detail="Image URL must be provided for images not in database, or found in database for provided ID")
|
| 52 |
+
|
| 53 |
+
image_bytes = await image_service.load_image(
|
| 54 |
+
image_id=request.image_id if request.image_id != "temp-id" else None,
|
| 55 |
+
image_url=image_url,
|
| 56 |
+
image_bytes=None,
|
| 57 |
+
filepath=None,
|
| 58 |
+
)
|
| 59 |
+
if not image_bytes:
|
| 60 |
+
raise HTTPException(status_code=404, detail="Image not found for analysis. Please ensure the URL is accessible.")
|
| 61 |
+
|
| 62 |
+
original_prompt = ad.get("image_prompt") if ad else None
|
| 63 |
+
result = await correction_service.correct_image(
|
| 64 |
+
image_bytes=image_bytes,
|
| 65 |
+
image_url=image_url,
|
| 66 |
+
original_prompt=original_prompt,
|
| 67 |
+
width=1024,
|
| 68 |
+
height=1024,
|
| 69 |
+
niche=ad.get("niche", "others") if ad else "others",
|
| 70 |
+
user_instructions=request.user_instructions,
|
| 71 |
+
auto_analyze=request.auto_analyze,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
response_data = {
|
| 75 |
+
"status": result["status"],
|
| 76 |
+
"analysis": result.get("analysis"),
|
| 77 |
+
"corrections": None,
|
| 78 |
+
"corrected_image": None,
|
| 79 |
+
"error": result.get("error"),
|
| 80 |
+
}
|
| 81 |
+
if result.get("corrections"):
|
| 82 |
+
c = result["corrections"]
|
| 83 |
+
response_data["corrections"] = {
|
| 84 |
+
"spelling_corrections": c.get("spelling_corrections", []),
|
| 85 |
+
"visual_corrections": c.get("visual_corrections", []),
|
| 86 |
+
"corrected_prompt": c.get("corrected_prompt", ""),
|
| 87 |
+
}
|
| 88 |
+
if result.get("corrected_image"):
|
| 89 |
+
ci = result["corrected_image"]
|
| 90 |
+
response_data["corrected_image"] = {
|
| 91 |
+
"filename": ci.get("filename"),
|
| 92 |
+
"filepath": ci.get("filepath"),
|
| 93 |
+
"image_url": ci.get("image_url"),
|
| 94 |
+
"r2_url": ci.get("r2_url"),
|
| 95 |
+
"model_used": ci.get("model_used"),
|
| 96 |
+
"corrected_prompt": ci.get("corrected_prompt"),
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
if result.get("status") == "success" and result.get("_db_metadata") and ad:
|
| 100 |
+
db_metadata = result["_db_metadata"]
|
| 101 |
+
correction_metadata = {
|
| 102 |
+
"is_corrected": True,
|
| 103 |
+
"correction_date": datetime.utcnow().isoformat() + "Z",
|
| 104 |
+
}
|
| 105 |
+
for k, v in [
|
| 106 |
+
("original_image_url", ad.get("r2_url") or ad.get("image_url")),
|
| 107 |
+
("original_r2_url", ad.get("r2_url")),
|
| 108 |
+
("original_image_filename", ad.get("image_filename")),
|
| 109 |
+
("original_image_model", ad.get("image_model")),
|
| 110 |
+
("original_image_prompt", ad.get("image_prompt")),
|
| 111 |
+
]:
|
| 112 |
+
if v:
|
| 113 |
+
correction_metadata[k] = v
|
| 114 |
+
if result.get("corrections"):
|
| 115 |
+
correction_metadata["corrections"] = result.get("corrections")
|
| 116 |
+
update_kwargs = {
|
| 117 |
+
"image_url": db_metadata.get("image_url"),
|
| 118 |
+
"image_filename": db_metadata.get("filename"),
|
| 119 |
+
"image_model": db_metadata.get("model_used"),
|
| 120 |
+
"image_prompt": db_metadata.get("corrected_prompt"),
|
| 121 |
+
}
|
| 122 |
+
if db_metadata.get("r2_url"):
|
| 123 |
+
update_kwargs["r2_url"] = db_metadata.get("r2_url")
|
| 124 |
+
update_success = await db_service.update_ad_creative(
|
| 125 |
+
ad_id=request.image_id,
|
| 126 |
+
username=username,
|
| 127 |
+
metadata=correction_metadata,
|
| 128 |
+
**update_kwargs,
|
| 129 |
+
)
|
| 130 |
+
if update_success and response_data.get("corrected_image") is not None:
|
| 131 |
+
response_data["corrected_image"]["ad_id"] = request.image_id
|
| 132 |
+
|
| 133 |
+
api_logger.info("Correction request completed in %.2fs", time.time() - api_start_time)
|
| 134 |
+
return response_data
|
| 135 |
+
except HTTPException:
|
| 136 |
+
raise
|
| 137 |
+
except Exception as e:
|
| 138 |
+
api_logger.exception("Correction failed")
|
| 139 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@router.post("/api/regenerate", response_model=ImageRegenerateResponse)
|
| 143 |
+
async def regenerate_image(
|
| 144 |
+
request: ImageRegenerateRequest,
|
| 145 |
+
username: str = Depends(get_current_user),
|
| 146 |
+
):
|
| 147 |
+
"""
|
| 148 |
+
Regenerate an image for an existing ad creative with an optional new model.
|
| 149 |
+
If preview_only=True, returns preview without updating DB; use confirm to save.
|
| 150 |
+
"""
|
| 151 |
+
api_start_time = time.time()
|
| 152 |
+
try:
|
| 153 |
+
ad = await db_service.get_ad_creative(request.image_id, username=username)
|
| 154 |
+
if not ad:
|
| 155 |
+
raise HTTPException(status_code=404, detail="Ad creative not found or access denied")
|
| 156 |
+
image_prompt = ad.get("image_prompt")
|
| 157 |
+
if not image_prompt:
|
| 158 |
+
raise HTTPException(status_code=400, detail="No image prompt found for this ad creative.")
|
| 159 |
+
model_to_use = request.image_model or ad.get("image_model") or settings.image_model
|
| 160 |
+
seed = random.randint(1, 2147483647)
|
| 161 |
+
image_bytes, model_used, generated_url = await image_service.generate(
|
| 162 |
+
prompt=image_prompt,
|
| 163 |
+
width=1024,
|
| 164 |
+
height=1024,
|
| 165 |
+
seed=seed,
|
| 166 |
+
model_key=model_to_use,
|
| 167 |
+
)
|
| 168 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 169 |
+
unique_id = uuid.uuid4().hex[:8]
|
| 170 |
+
niche = ad.get("niche", "unknown").replace(" ", "_")
|
| 171 |
+
filename = f"regen_{niche}_{timestamp}_{unique_id}.png"
|
| 172 |
+
r2_url = None
|
| 173 |
+
try:
|
| 174 |
+
from services.r2_storage import get_r2_storage
|
| 175 |
+
r2_storage = get_r2_storage()
|
| 176 |
+
if r2_storage and image_bytes:
|
| 177 |
+
r2_url = r2_storage.upload_image(image_bytes=image_bytes, filename=filename, niche=niche)
|
| 178 |
+
except Exception as e:
|
| 179 |
+
api_logger.warning("R2 upload failed: %s", e)
|
| 180 |
+
local_path = None
|
| 181 |
+
if not r2_url and image_bytes:
|
| 182 |
+
local_path = os.path.join(settings.output_dir, filename)
|
| 183 |
+
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
| 184 |
+
with open(local_path, "wb") as f:
|
| 185 |
+
f.write(image_bytes)
|
| 186 |
+
original_image_url = ad.get("r2_url") or ad.get("image_url")
|
| 187 |
+
new_image_url = r2_url or generated_url or f"/images/{filename}"
|
| 188 |
+
|
| 189 |
+
if request.preview_only:
|
| 190 |
+
return {
|
| 191 |
+
"status": "success",
|
| 192 |
+
"regenerated_image": {
|
| 193 |
+
"filename": filename,
|
| 194 |
+
"filepath": local_path,
|
| 195 |
+
"image_url": new_image_url,
|
| 196 |
+
"r2_url": r2_url,
|
| 197 |
+
"model_used": model_used,
|
| 198 |
+
"prompt_used": image_prompt,
|
| 199 |
+
"seed_used": seed,
|
| 200 |
+
},
|
| 201 |
+
"original_image_url": original_image_url,
|
| 202 |
+
"original_preserved": True,
|
| 203 |
+
"is_preview": True,
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
regeneration_metadata = {
|
| 207 |
+
"is_regenerated": True,
|
| 208 |
+
"regeneration_date": datetime.utcnow().isoformat() + "Z",
|
| 209 |
+
"regeneration_seed": seed,
|
| 210 |
+
}
|
| 211 |
+
if original_image_url:
|
| 212 |
+
regeneration_metadata["original_image_url"] = original_image_url
|
| 213 |
+
for k, v in [("original_r2_url", ad.get("r2_url")), ("original_image_filename", ad.get("image_filename")), ("original_image_model", ad.get("image_model")), ("original_seed", ad.get("image_seed"))]:
|
| 214 |
+
if v is not None:
|
| 215 |
+
regeneration_metadata[k] = v
|
| 216 |
+
update_kwargs = {"image_filename": filename, "image_model": model_used, "image_seed": seed}
|
| 217 |
+
if r2_url:
|
| 218 |
+
update_kwargs["image_url"] = update_kwargs["r2_url"] = r2_url
|
| 219 |
+
elif generated_url:
|
| 220 |
+
update_kwargs["image_url"] = generated_url
|
| 221 |
+
elif local_path:
|
| 222 |
+
update_kwargs["image_url"] = f"/images/{filename}"
|
| 223 |
+
await db_service.update_ad_creative(
|
| 224 |
+
ad_id=request.image_id,
|
| 225 |
+
username=username,
|
| 226 |
+
metadata=regeneration_metadata,
|
| 227 |
+
**update_kwargs,
|
| 228 |
+
)
|
| 229 |
+
return {
|
| 230 |
+
"status": "success",
|
| 231 |
+
"regenerated_image": {
|
| 232 |
+
"filename": filename,
|
| 233 |
+
"filepath": local_path,
|
| 234 |
+
"image_url": new_image_url,
|
| 235 |
+
"r2_url": r2_url,
|
| 236 |
+
"model_used": model_used,
|
| 237 |
+
"prompt_used": image_prompt,
|
| 238 |
+
"seed_used": seed,
|
| 239 |
+
},
|
| 240 |
+
"original_image_url": original_image_url,
|
| 241 |
+
"original_preserved": True,
|
| 242 |
+
"is_preview": False,
|
| 243 |
+
}
|
| 244 |
+
except HTTPException:
|
| 245 |
+
raise
|
| 246 |
+
except Exception as e:
|
| 247 |
+
api_logger.exception("Regeneration failed")
|
| 248 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
@router.post("/api/regenerate/confirm")
|
| 252 |
+
async def confirm_image_selection(
|
| 253 |
+
request: ImageSelectionRequest,
|
| 254 |
+
username: str = Depends(get_current_user),
|
| 255 |
+
):
|
| 256 |
+
"""
|
| 257 |
+
Confirm the user's image selection after regeneration preview.
|
| 258 |
+
selection='new' updates the ad with the new image; selection='original' keeps original.
|
| 259 |
+
"""
|
| 260 |
+
if request.selection not in ["new", "original"]:
|
| 261 |
+
raise HTTPException(status_code=400, detail="Selection must be 'new' or 'original'")
|
| 262 |
+
ad = await db_service.get_ad_creative(request.image_id, username=username)
|
| 263 |
+
if not ad:
|
| 264 |
+
raise HTTPException(status_code=404, detail="Ad creative not found or access denied")
|
| 265 |
+
if request.selection == "original":
|
| 266 |
+
return {"status": "success", "message": "Original image kept", "selection": "original"}
|
| 267 |
+
if not request.new_image_url:
|
| 268 |
+
raise HTTPException(status_code=400, detail="new_image_url is required when selection='new'")
|
| 269 |
+
regeneration_metadata = {
|
| 270 |
+
"is_regenerated": True,
|
| 271 |
+
"regeneration_date": datetime.utcnow().isoformat() + "Z",
|
| 272 |
+
"regeneration_seed": request.new_seed,
|
| 273 |
+
}
|
| 274 |
+
for k, v in [
|
| 275 |
+
("original_image_url", ad.get("r2_url") or ad.get("image_url")),
|
| 276 |
+
("original_r2_url", ad.get("r2_url")),
|
| 277 |
+
("original_image_filename", ad.get("image_filename")),
|
| 278 |
+
("original_image_model", ad.get("image_model")),
|
| 279 |
+
("original_seed", ad.get("image_seed")),
|
| 280 |
+
]:
|
| 281 |
+
if v is not None:
|
| 282 |
+
regeneration_metadata[k] = v
|
| 283 |
+
update_kwargs = {}
|
| 284 |
+
if request.new_filename:
|
| 285 |
+
update_kwargs["image_filename"] = request.new_filename
|
| 286 |
+
if request.new_model:
|
| 287 |
+
update_kwargs["image_model"] = request.new_model
|
| 288 |
+
if request.new_seed is not None:
|
| 289 |
+
update_kwargs["image_seed"] = request.new_seed
|
| 290 |
+
if request.new_r2_url:
|
| 291 |
+
update_kwargs["image_url"] = update_kwargs["r2_url"] = request.new_r2_url
|
| 292 |
+
elif request.new_image_url:
|
| 293 |
+
update_kwargs["image_url"] = request.new_image_url
|
| 294 |
+
update_success = await db_service.update_ad_creative(
|
| 295 |
+
ad_id=request.image_id,
|
| 296 |
+
username=username,
|
| 297 |
+
metadata=regeneration_metadata,
|
| 298 |
+
**update_kwargs,
|
| 299 |
+
)
|
| 300 |
+
if not update_success:
|
| 301 |
+
raise HTTPException(status_code=500, detail="Failed to update ad with new image")
|
| 302 |
+
return {"status": "success", "message": "New image saved", "selection": "new", "new_image_url": request.new_image_url}
|
api/routers/creative.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Creative upload, analyze, and modify endpoints."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from fastapi import APIRouter, HTTPException, Depends, File, UploadFile
|
| 6 |
+
|
| 7 |
+
from api.schemas import (
|
| 8 |
+
CreativeAnalyzeRequest,
|
| 9 |
+
CreativeAnalysisResponse,
|
| 10 |
+
CreativeModifyRequest,
|
| 11 |
+
CreativeModifyResponse,
|
| 12 |
+
FileUploadResponse,
|
| 13 |
+
)
|
| 14 |
+
from services.creative_modifier import creative_modifier_service
|
| 15 |
+
from services.image import image_service
|
| 16 |
+
from services.auth_dependency import get_current_user
|
| 17 |
+
from config import settings
|
| 18 |
+
|
| 19 |
+
router = APIRouter(tags=["creative"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.post("/api/creative/upload", response_model=FileUploadResponse)
|
| 23 |
+
async def upload_creative(
|
| 24 |
+
file: UploadFile = File(...),
|
| 25 |
+
username: str = Depends(get_current_user),
|
| 26 |
+
):
|
| 27 |
+
"""
|
| 28 |
+
Upload a creative image for analysis and modification.
|
| 29 |
+
Accepts PNG, JPG, JPEG, WebP. Returns image URL for subsequent steps.
|
| 30 |
+
"""
|
| 31 |
+
allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/webp"]
|
| 32 |
+
if file.content_type not in allowed_types:
|
| 33 |
+
raise HTTPException(status_code=400, detail=f"Invalid file type. Allowed: PNG, JPG, JPEG, WebP. Got: {file.content_type}")
|
| 34 |
+
contents = await file.read()
|
| 35 |
+
if len(contents) > 10 * 1024 * 1024:
|
| 36 |
+
raise HTTPException(status_code=400, detail="File too large. Maximum size is 10MB.")
|
| 37 |
+
try:
|
| 38 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 39 |
+
unique_id = __import__("uuid").uuid4().hex[:8]
|
| 40 |
+
ext = file.filename.split(".")[-1] if file.filename else "png"
|
| 41 |
+
filename = f"upload_{username}_{timestamp}_{unique_id}.{ext}"
|
| 42 |
+
r2_url = None
|
| 43 |
+
try:
|
| 44 |
+
from services.r2_storage import get_r2_storage
|
| 45 |
+
r2_storage = get_r2_storage()
|
| 46 |
+
if r2_storage:
|
| 47 |
+
r2_url = r2_storage.upload_image(image_bytes=contents, filename=filename, niche="uploads")
|
| 48 |
+
except Exception:
|
| 49 |
+
pass
|
| 50 |
+
if not r2_url:
|
| 51 |
+
local_path = os.path.join(settings.output_dir, filename)
|
| 52 |
+
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
| 53 |
+
with open(local_path, "wb") as f:
|
| 54 |
+
f.write(contents)
|
| 55 |
+
r2_url = f"/images/{filename}"
|
| 56 |
+
return {"status": "success", "image_url": r2_url, "filename": filename}
|
| 57 |
+
except Exception as e:
|
| 58 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.post("/api/creative/analyze", response_model=CreativeAnalysisResponse)
|
| 62 |
+
async def analyze_creative(
|
| 63 |
+
request: CreativeAnalyzeRequest,
|
| 64 |
+
username: str = Depends(get_current_user),
|
| 65 |
+
):
|
| 66 |
+
"""Analyze a creative image using AI vision (via URL)."""
|
| 67 |
+
if not request.image_url:
|
| 68 |
+
raise HTTPException(status_code=400, detail="image_url must be provided")
|
| 69 |
+
try:
|
| 70 |
+
image_bytes = await image_service.load_image(image_url=request.image_url)
|
| 71 |
+
except Exception as e:
|
| 72 |
+
raise HTTPException(status_code=400, detail=f"Failed to fetch image from URL: {e}")
|
| 73 |
+
if not image_bytes:
|
| 74 |
+
raise HTTPException(status_code=400, detail="Failed to load image")
|
| 75 |
+
try:
|
| 76 |
+
result = await creative_modifier_service.analyze_creative(image_bytes)
|
| 77 |
+
if result["status"] != "success":
|
| 78 |
+
return CreativeAnalysisResponse(status="error", error=result.get("error", "Analysis failed"))
|
| 79 |
+
return CreativeAnalysisResponse(
|
| 80 |
+
status="success",
|
| 81 |
+
analysis=result.get("analysis"),
|
| 82 |
+
suggested_angles=result.get("suggested_angles"),
|
| 83 |
+
suggested_concepts=result.get("suggested_concepts"),
|
| 84 |
+
)
|
| 85 |
+
except Exception as e:
|
| 86 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.post("/api/creative/analyze/upload", response_model=CreativeAnalysisResponse)
|
| 90 |
+
async def analyze_creative_upload(
|
| 91 |
+
file: UploadFile = File(...),
|
| 92 |
+
username: str = Depends(get_current_user),
|
| 93 |
+
):
|
| 94 |
+
"""Analyze a creative image using AI vision (via file upload)."""
|
| 95 |
+
allowed_types = ["image/png", "image/jpeg", "image/jpg", "image/webp"]
|
| 96 |
+
if file.content_type not in allowed_types:
|
| 97 |
+
raise HTTPException(status_code=400, detail=f"Invalid file type. Allowed: PNG, JPG, JPEG, WebP. Got: {file.content_type}")
|
| 98 |
+
image_bytes = await file.read()
|
| 99 |
+
if not image_bytes:
|
| 100 |
+
raise HTTPException(status_code=400, detail="Failed to load image")
|
| 101 |
+
try:
|
| 102 |
+
result = await creative_modifier_service.analyze_creative(image_bytes)
|
| 103 |
+
if result["status"] != "success":
|
| 104 |
+
return CreativeAnalysisResponse(status="error", error=result.get("error", "Analysis failed"))
|
| 105 |
+
return CreativeAnalysisResponse(
|
| 106 |
+
status="success",
|
| 107 |
+
analysis=result.get("analysis"),
|
| 108 |
+
suggested_angles=result.get("suggested_angles"),
|
| 109 |
+
suggested_concepts=result.get("suggested_concepts"),
|
| 110 |
+
)
|
| 111 |
+
except Exception as e:
|
| 112 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.post("/api/creative/modify", response_model=CreativeModifyResponse)
|
| 116 |
+
async def modify_creative(
|
| 117 |
+
request: CreativeModifyRequest,
|
| 118 |
+
username: str = Depends(get_current_user),
|
| 119 |
+
):
|
| 120 |
+
"""
|
| 121 |
+
Modify a creative with angle and/or concept.
|
| 122 |
+
Modes: 'modify' (image-to-image) or 'inspired' (new generation).
|
| 123 |
+
"""
|
| 124 |
+
if not request.angle and not request.concept:
|
| 125 |
+
raise HTTPException(status_code=400, detail="At least one of 'angle' or 'concept' must be provided")
|
| 126 |
+
analysis = request.analysis
|
| 127 |
+
if not analysis:
|
| 128 |
+
try:
|
| 129 |
+
image_bytes = await image_service.load_image(image_url=request.image_url)
|
| 130 |
+
if not image_bytes:
|
| 131 |
+
raise HTTPException(status_code=400, detail="Failed to load image from URL")
|
| 132 |
+
analysis_result = await creative_modifier_service.analyze_creative(image_bytes)
|
| 133 |
+
if analysis_result["status"] != "success":
|
| 134 |
+
raise HTTPException(status_code=500, detail=analysis_result.get("error", "Analysis failed"))
|
| 135 |
+
analysis = analysis_result.get("analysis", {})
|
| 136 |
+
except HTTPException:
|
| 137 |
+
raise
|
| 138 |
+
except Exception as e:
|
| 139 |
+
raise HTTPException(status_code=500, detail=f"Failed to analyze image: {e}")
|
| 140 |
+
try:
|
| 141 |
+
result = await creative_modifier_service.modify_creative(
|
| 142 |
+
image_url=request.image_url,
|
| 143 |
+
analysis=analysis,
|
| 144 |
+
user_angle=request.angle,
|
| 145 |
+
user_concept=request.concept,
|
| 146 |
+
mode=request.mode,
|
| 147 |
+
image_model=request.image_model,
|
| 148 |
+
user_prompt=request.user_prompt,
|
| 149 |
+
)
|
| 150 |
+
if result["status"] != "success":
|
| 151 |
+
return CreativeModifyResponse(status="error", error=result.get("error", "Modification failed"))
|
| 152 |
+
return CreativeModifyResponse(status="success", prompt=result.get("prompt"), image=result.get("image"))
|
| 153 |
+
except Exception as e:
|
| 154 |
+
raise HTTPException(status_code=500, detail=str(e))
|
api/routers/database.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database endpoints: stats, list ads, get/delete ad, edit copy."""
|
| 2 |
+
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 5 |
+
|
| 6 |
+
from api.schemas import DbStatsResponse, EditAdCopyRequest
|
| 7 |
+
from services.database import db_service
|
| 8 |
+
from services.auth_dependency import get_current_user
|
| 9 |
+
|
| 10 |
+
router = APIRouter(tags=["database"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/db/stats", response_model=DbStatsResponse)
|
| 14 |
+
async def get_database_stats(username: str = Depends(get_current_user)):
|
| 15 |
+
"""Get statistics about stored ad creatives for the current user."""
|
| 16 |
+
return await db_service.get_stats(username=username)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/db/ads")
|
| 20 |
+
async def list_stored_ads(
|
| 21 |
+
niche: Optional[str] = None,
|
| 22 |
+
generation_method: Optional[str] = None,
|
| 23 |
+
limit: int = 50,
|
| 24 |
+
offset: int = 0,
|
| 25 |
+
username: str = Depends(get_current_user),
|
| 26 |
+
):
|
| 27 |
+
"""List ad creatives for the current user with optional filters and pagination."""
|
| 28 |
+
ads, total = await db_service.list_ad_creatives(
|
| 29 |
+
username=username,
|
| 30 |
+
niche=niche,
|
| 31 |
+
generation_method=generation_method,
|
| 32 |
+
limit=limit,
|
| 33 |
+
offset=offset,
|
| 34 |
+
)
|
| 35 |
+
return {
|
| 36 |
+
"total": total,
|
| 37 |
+
"limit": limit,
|
| 38 |
+
"offset": offset,
|
| 39 |
+
"ads": [
|
| 40 |
+
{
|
| 41 |
+
"id": str(ad.get("id", "")),
|
| 42 |
+
"niche": ad.get("niche", ""),
|
| 43 |
+
"title": ad.get("title"),
|
| 44 |
+
"headline": ad.get("headline", ""),
|
| 45 |
+
"primary_text": ad.get("primary_text"),
|
| 46 |
+
"description": ad.get("description"),
|
| 47 |
+
"body_story": ad.get("body_story"),
|
| 48 |
+
"cta": ad.get("cta", ""),
|
| 49 |
+
"psychological_angle": ad.get("psychological_angle", ""),
|
| 50 |
+
"image_url": ad.get("image_url"),
|
| 51 |
+
"r2_url": ad.get("r2_url"),
|
| 52 |
+
"image_filename": ad.get("image_filename"),
|
| 53 |
+
"image_model": ad.get("image_model"),
|
| 54 |
+
"angle_key": ad.get("angle_key"),
|
| 55 |
+
"concept_key": ad.get("concept_key"),
|
| 56 |
+
"generation_method": ad.get("generation_method", "standard"),
|
| 57 |
+
"created_at": ad.get("created_at"),
|
| 58 |
+
}
|
| 59 |
+
for ad in ads
|
| 60 |
+
],
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.get("/db/ad/{ad_id}")
|
| 65 |
+
async def get_stored_ad(ad_id: str):
|
| 66 |
+
"""Get a specific ad creative by ID."""
|
| 67 |
+
ad = await db_service.get_ad_creative(ad_id)
|
| 68 |
+
if not ad:
|
| 69 |
+
raise HTTPException(status_code=404, detail=f"Ad '{ad_id}' not found")
|
| 70 |
+
return {
|
| 71 |
+
"id": str(ad.get("id", "")),
|
| 72 |
+
"niche": ad.get("niche", ""),
|
| 73 |
+
"title": ad.get("title"),
|
| 74 |
+
"headline": ad.get("headline", ""),
|
| 75 |
+
"primary_text": ad.get("primary_text"),
|
| 76 |
+
"description": ad.get("description"),
|
| 77 |
+
"body_story": ad.get("body_story"),
|
| 78 |
+
"cta": ad.get("cta", ""),
|
| 79 |
+
"psychological_angle": ad.get("psychological_angle", ""),
|
| 80 |
+
"why_it_works": ad.get("why_it_works"),
|
| 81 |
+
"image_url": ad.get("image_url"),
|
| 82 |
+
"image_filename": ad.get("image_filename"),
|
| 83 |
+
"image_model": ad.get("image_model"),
|
| 84 |
+
"image_seed": ad.get("image_seed"),
|
| 85 |
+
"r2_url": ad.get("r2_url"),
|
| 86 |
+
"angle_key": ad.get("angle_key"),
|
| 87 |
+
"angle_name": ad.get("angle_name"),
|
| 88 |
+
"angle_trigger": ad.get("angle_trigger"),
|
| 89 |
+
"angle_category": ad.get("angle_category"),
|
| 90 |
+
"concept_key": ad.get("concept_key"),
|
| 91 |
+
"concept_name": ad.get("concept_name"),
|
| 92 |
+
"concept_structure": ad.get("concept_structure"),
|
| 93 |
+
"concept_visual": ad.get("concept_visual"),
|
| 94 |
+
"concept_category": ad.get("concept_category"),
|
| 95 |
+
"generation_method": ad.get("generation_method", "standard"),
|
| 96 |
+
"metadata": ad.get("metadata"),
|
| 97 |
+
"created_at": ad.get("created_at"),
|
| 98 |
+
"updated_at": ad.get("updated_at"),
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.delete("/db/ad/{ad_id}")
|
| 103 |
+
async def delete_stored_ad(ad_id: str, username: str = Depends(get_current_user)):
|
| 104 |
+
"""Delete an ad creative. Users can only delete their own ads."""
|
| 105 |
+
success = await db_service.delete_ad_creative(ad_id, username=username)
|
| 106 |
+
if not success:
|
| 107 |
+
raise HTTPException(status_code=404, detail=f"Ad '{ad_id}' not found or could not be deleted")
|
| 108 |
+
return {"success": True, "deleted_id": ad_id}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.post("/db/ad/edit")
|
| 112 |
+
async def edit_ad_copy(
|
| 113 |
+
request: EditAdCopyRequest,
|
| 114 |
+
username: str = Depends(get_current_user),
|
| 115 |
+
):
|
| 116 |
+
"""
|
| 117 |
+
Edit ad copy fields. Modes: manual (direct update) or ai (AI-improved version).
|
| 118 |
+
"""
|
| 119 |
+
from services.llm import LLMService
|
| 120 |
+
|
| 121 |
+
ad = await db_service.get_ad_creative(request.ad_id)
|
| 122 |
+
if not ad:
|
| 123 |
+
raise HTTPException(status_code=404, detail=f"Ad '{request.ad_id}' not found")
|
| 124 |
+
if ad.get("username") != username:
|
| 125 |
+
raise HTTPException(status_code=403, detail="You can only edit your own ads")
|
| 126 |
+
|
| 127 |
+
if request.mode == "manual":
|
| 128 |
+
success = await db_service.update_ad_creative(
|
| 129 |
+
ad_id=request.ad_id,
|
| 130 |
+
username=username,
|
| 131 |
+
**{request.field: request.value},
|
| 132 |
+
)
|
| 133 |
+
if not success:
|
| 134 |
+
raise HTTPException(status_code=500, detail="Failed to update ad")
|
| 135 |
+
return {"edited_value": request.value, "success": True}
|
| 136 |
+
|
| 137 |
+
llm_service = LLMService()
|
| 138 |
+
field_labels = {
|
| 139 |
+
"title": "title",
|
| 140 |
+
"headline": "headline",
|
| 141 |
+
"primary_text": "primary text",
|
| 142 |
+
"description": "description",
|
| 143 |
+
"body_story": "body story",
|
| 144 |
+
"cta": "call to action",
|
| 145 |
+
}
|
| 146 |
+
field_label = field_labels.get(request.field, request.field)
|
| 147 |
+
current_value = request.value
|
| 148 |
+
niche = ad.get("niche", "general")
|
| 149 |
+
system_prompt = f"""You are an expert copywriter specializing in high-converting ad copy for {niche.replace('_', ' ')}.
|
| 150 |
+
Your task is to improve the {field_label} while maintaining its core message and emotional impact.
|
| 151 |
+
Keep the same tone and style, but make it more compelling, clear, and effective."""
|
| 152 |
+
user_prompt = f"""Current {field_label}:\n{current_value}\n\n"""
|
| 153 |
+
if request.user_suggestion:
|
| 154 |
+
user_prompt += f"User's suggestion: {request.user_suggestion}\n\n"
|
| 155 |
+
user_prompt += f"""Please provide an improved version of this {field_label} that:
|
| 156 |
+
1. Maintains the core message and emotional impact
|
| 157 |
+
2. Is more compelling and engaging
|
| 158 |
+
3. Follows best practices for {field_label} in ad copy
|
| 159 |
+
4. {"Incorporates the user's suggestion" if request.user_suggestion else "Is optimized for conversion"}
|
| 160 |
+
|
| 161 |
+
Return ONLY the improved {field_label} text, without any explanations or additional text."""
|
| 162 |
+
try:
|
| 163 |
+
edited_value = await llm_service.generate(
|
| 164 |
+
prompt=user_prompt,
|
| 165 |
+
system_prompt=system_prompt,
|
| 166 |
+
temperature=0.7,
|
| 167 |
+
)
|
| 168 |
+
edited_value = edited_value.strip().strip('"').strip("'")
|
| 169 |
+
return {"edited_value": edited_value, "success": True}
|
| 170 |
+
except Exception as e:
|
| 171 |
+
raise HTTPException(status_code=500, detail=f"Failed to generate AI edit: {str(e)}")
|
api/routers/export.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bulk export endpoint."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import logging
|
| 5 |
+
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
|
| 6 |
+
from fastapi.responses import FileResponse
|
| 7 |
+
|
| 8 |
+
from api.schemas import BulkExportRequest
|
| 9 |
+
from services.database import db_service
|
| 10 |
+
from services.export_service import export_service
|
| 11 |
+
from services.auth_dependency import get_current_user
|
| 12 |
+
|
| 13 |
+
router = APIRouter(tags=["export"])
|
| 14 |
+
api_logger = logging.getLogger("api")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.post("/api/export/bulk")
|
| 18 |
+
async def export_bulk_ads(
|
| 19 |
+
request: BulkExportRequest,
|
| 20 |
+
background_tasks: BackgroundTasks,
|
| 21 |
+
username: str = Depends(get_current_user),
|
| 22 |
+
):
|
| 23 |
+
"""
|
| 24 |
+
Export multiple ad creatives as a ZIP package.
|
| 25 |
+
Creates /creatives/ folder and ad_copy_data.xlsx. Max 50 ads.
|
| 26 |
+
"""
|
| 27 |
+
if len(request.ad_ids) > 50:
|
| 28 |
+
raise HTTPException(status_code=400, detail="Maximum 50 ads can be exported at once")
|
| 29 |
+
ads = []
|
| 30 |
+
for ad_id in request.ad_ids:
|
| 31 |
+
ad = await db_service.get_ad_creative(ad_id, username=username)
|
| 32 |
+
if not ad:
|
| 33 |
+
raise HTTPException(status_code=404, detail=f"Ad '{ad_id}' not found or access denied")
|
| 34 |
+
ads.append(ad)
|
| 35 |
+
try:
|
| 36 |
+
api_logger.info("Creating export package for %d ads (user: %s)", len(ads), username)
|
| 37 |
+
zip_path = await export_service.create_export_package(ads)
|
| 38 |
+
background_tasks.add_task(export_service.cleanup_zip, zip_path)
|
| 39 |
+
return FileResponse(
|
| 40 |
+
zip_path,
|
| 41 |
+
media_type="application/zip",
|
| 42 |
+
filename=os.path.basename(zip_path),
|
| 43 |
+
)
|
| 44 |
+
except HTTPException:
|
| 45 |
+
raise
|
| 46 |
+
except Exception as e:
|
| 47 |
+
api_logger.error("Bulk export failed: %s", e)
|
| 48 |
+
raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
|
api/routers/extensive.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extensive generation (researcher → creative director → designer → copywriter)."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import uuid
|
| 5 |
+
from typing import Dict, Any, Optional
|
| 6 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 7 |
+
|
| 8 |
+
from api.schemas import ExtensiveGenerateRequest, ExtensiveJobResponse, BatchResponse
|
| 9 |
+
from services.generator import ad_generator
|
| 10 |
+
from services.auth_dependency import get_current_user
|
| 11 |
+
|
| 12 |
+
router = APIRouter(tags=["extensive"])
|
| 13 |
+
|
| 14 |
+
_extensive_jobs: Dict[str, Dict[str, Any]] = {}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def _run_extensive_job_async(
|
| 18 |
+
job_id: str,
|
| 19 |
+
username: str,
|
| 20 |
+
effective_niche: str,
|
| 21 |
+
target_audience: Optional[str],
|
| 22 |
+
offer: Optional[str],
|
| 23 |
+
num_images: int,
|
| 24 |
+
image_model: Optional[str],
|
| 25 |
+
num_strategies: int,
|
| 26 |
+
):
|
| 27 |
+
"""Run extensive generation on the main event loop."""
|
| 28 |
+
import logging
|
| 29 |
+
api_logger = logging.getLogger("api")
|
| 30 |
+
try:
|
| 31 |
+
results = await ad_generator.generate_ad_extensive(
|
| 32 |
+
niche=effective_niche,
|
| 33 |
+
target_audience=target_audience,
|
| 34 |
+
offer=offer,
|
| 35 |
+
num_images=num_images,
|
| 36 |
+
image_model=image_model,
|
| 37 |
+
num_strategies=num_strategies,
|
| 38 |
+
username=username,
|
| 39 |
+
)
|
| 40 |
+
_extensive_jobs[job_id]["status"] = "completed"
|
| 41 |
+
_extensive_jobs[job_id]["result"] = BatchResponse(count=len(results), ads=results)
|
| 42 |
+
except Exception as e:
|
| 43 |
+
api_logger.exception("Extensive job %s failed", job_id)
|
| 44 |
+
_extensive_jobs[job_id]["status"] = "failed"
|
| 45 |
+
_extensive_jobs[job_id]["error"] = str(e)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.post("/extensive/generate", status_code=202)
|
| 49 |
+
async def generate_extensive(
|
| 50 |
+
request: ExtensiveGenerateRequest,
|
| 51 |
+
username: str = Depends(get_current_user),
|
| 52 |
+
):
|
| 53 |
+
"""
|
| 54 |
+
Start extensive ad generation. Returns 202 with job_id.
|
| 55 |
+
Poll GET /extensive/status/{job_id} then GET /extensive/result/{job_id}.
|
| 56 |
+
"""
|
| 57 |
+
if request.niche == "others":
|
| 58 |
+
if not request.custom_niche or not request.custom_niche.strip():
|
| 59 |
+
raise HTTPException(status_code=400, detail="custom_niche is required when niche is 'others'")
|
| 60 |
+
effective_niche = request.custom_niche.strip()
|
| 61 |
+
else:
|
| 62 |
+
effective_niche = request.niche
|
| 63 |
+
|
| 64 |
+
job_id = str(uuid.uuid4())
|
| 65 |
+
_extensive_jobs[job_id] = {
|
| 66 |
+
"status": "running",
|
| 67 |
+
"result": None,
|
| 68 |
+
"error": None,
|
| 69 |
+
"username": username,
|
| 70 |
+
}
|
| 71 |
+
asyncio.create_task(
|
| 72 |
+
_run_extensive_job_async(
|
| 73 |
+
job_id,
|
| 74 |
+
username,
|
| 75 |
+
effective_niche,
|
| 76 |
+
request.target_audience,
|
| 77 |
+
request.offer,
|
| 78 |
+
request.num_images,
|
| 79 |
+
request.image_model,
|
| 80 |
+
request.num_strategies,
|
| 81 |
+
)
|
| 82 |
+
)
|
| 83 |
+
return ExtensiveJobResponse(job_id=job_id)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.get("/extensive/status/{job_id}")
|
| 87 |
+
async def extensive_job_status(
|
| 88 |
+
job_id: str,
|
| 89 |
+
username: str = Depends(get_current_user),
|
| 90 |
+
):
|
| 91 |
+
"""Get status of an extensive generation job."""
|
| 92 |
+
if job_id not in _extensive_jobs:
|
| 93 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 94 |
+
job = _extensive_jobs[job_id]
|
| 95 |
+
if job["username"] != username:
|
| 96 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 97 |
+
return {
|
| 98 |
+
"job_id": job_id,
|
| 99 |
+
"status": job["status"],
|
| 100 |
+
"error": job.get("error") if job["status"] == "failed" else None,
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.get("/extensive/result/{job_id}", response_model=BatchResponse)
|
| 105 |
+
async def extensive_job_result(
|
| 106 |
+
job_id: str,
|
| 107 |
+
username: str = Depends(get_current_user),
|
| 108 |
+
):
|
| 109 |
+
"""Get result of a completed extensive generation job. 425 if still running."""
|
| 110 |
+
if job_id not in _extensive_jobs:
|
| 111 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 112 |
+
job = _extensive_jobs[job_id]
|
| 113 |
+
if job["username"] != username:
|
| 114 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 115 |
+
if job["status"] == "running":
|
| 116 |
+
raise HTTPException(status_code=425, detail="Generation still in progress")
|
| 117 |
+
if job["status"] == "failed":
|
| 118 |
+
raise HTTPException(status_code=500, detail=job.get("error", "Generation failed"))
|
| 119 |
+
return job["result"]
|
api/routers/generate.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ad generation endpoints (single, batch, image, strategies, models)."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from typing import Literal, Optional
|
| 5 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 6 |
+
from fastapi.responses import FileResponse, Response as FastAPIResponse
|
| 7 |
+
|
| 8 |
+
from api.schemas import (
|
| 9 |
+
GenerateRequest,
|
| 10 |
+
GenerateResponse,
|
| 11 |
+
GenerateBatchRequest,
|
| 12 |
+
BatchResponse,
|
| 13 |
+
)
|
| 14 |
+
from services.generator import ad_generator
|
| 15 |
+
from services.auth_dependency import get_current_user
|
| 16 |
+
from config import settings
|
| 17 |
+
|
| 18 |
+
router = APIRouter(tags=["generate"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.post("/generate", response_model=GenerateResponse)
|
| 22 |
+
async def generate(
|
| 23 |
+
request: GenerateRequest,
|
| 24 |
+
username: str = Depends(get_current_user),
|
| 25 |
+
):
|
| 26 |
+
"""
|
| 27 |
+
Generate a single ad creative.
|
| 28 |
+
Requires authentication. Uses randomization for strategies, hooks, visuals.
|
| 29 |
+
"""
|
| 30 |
+
try:
|
| 31 |
+
return await ad_generator.generate_ad(
|
| 32 |
+
niche=request.niche,
|
| 33 |
+
num_images=request.num_images,
|
| 34 |
+
image_model=request.image_model,
|
| 35 |
+
username=username,
|
| 36 |
+
target_audience=request.target_audience,
|
| 37 |
+
offer=request.offer,
|
| 38 |
+
use_trending=request.use_trending,
|
| 39 |
+
trending_context=request.trending_context,
|
| 40 |
+
)
|
| 41 |
+
except Exception as e:
|
| 42 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.post("/generate/batch", response_model=BatchResponse)
|
| 46 |
+
async def generate_batch(
|
| 47 |
+
request: GenerateBatchRequest,
|
| 48 |
+
username: str = Depends(get_current_user),
|
| 49 |
+
):
|
| 50 |
+
"""
|
| 51 |
+
Generate multiple ad creatives in batch.
|
| 52 |
+
Requires authentication. Each ad is unique due to randomization.
|
| 53 |
+
"""
|
| 54 |
+
try:
|
| 55 |
+
results = await ad_generator.generate_batch(
|
| 56 |
+
niche=request.niche,
|
| 57 |
+
count=request.count,
|
| 58 |
+
images_per_ad=request.images_per_ad,
|
| 59 |
+
image_model=request.image_model,
|
| 60 |
+
username=username,
|
| 61 |
+
method=request.method,
|
| 62 |
+
target_audience=request.target_audience,
|
| 63 |
+
offer=request.offer,
|
| 64 |
+
)
|
| 65 |
+
return {"count": len(results), "ads": results}
|
| 66 |
+
except Exception as e:
|
| 67 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/image/{filename}")
|
| 71 |
+
async def get_image(filename: str):
|
| 72 |
+
"""Get a generated image by filename."""
|
| 73 |
+
filepath = os.path.join(settings.output_dir, filename)
|
| 74 |
+
if not os.path.exists(filepath):
|
| 75 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 76 |
+
return FileResponse(filepath)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.get("/api/download-image")
|
| 80 |
+
async def download_image_proxy(
|
| 81 |
+
image_url: Optional[str] = None,
|
| 82 |
+
image_id: Optional[str] = None,
|
| 83 |
+
username: str = Depends(get_current_user),
|
| 84 |
+
):
|
| 85 |
+
"""
|
| 86 |
+
Proxy endpoint to download images, avoiding CORS.
|
| 87 |
+
Can fetch from external URLs (R2, Replicate) or local files.
|
| 88 |
+
"""
|
| 89 |
+
import httpx
|
| 90 |
+
|
| 91 |
+
from services.database import db_service
|
| 92 |
+
|
| 93 |
+
filename = None
|
| 94 |
+
if image_id:
|
| 95 |
+
ad = await db_service.get_ad_creative(image_id)
|
| 96 |
+
if not ad:
|
| 97 |
+
raise HTTPException(status_code=404, detail="Ad not found")
|
| 98 |
+
if ad.get("username") != username:
|
| 99 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 100 |
+
if not image_url:
|
| 101 |
+
image_url = ad.get("r2_url") or ad.get("image_url")
|
| 102 |
+
filename = ad.get("image_filename")
|
| 103 |
+
else:
|
| 104 |
+
metadata = ad.get("metadata", {})
|
| 105 |
+
if metadata.get("original_r2_url") == image_url or metadata.get("original_image_url") == image_url:
|
| 106 |
+
filename = metadata.get("original_image_filename")
|
| 107 |
+
|
| 108 |
+
if not image_url:
|
| 109 |
+
raise HTTPException(status_code=400, detail="No image URL provided")
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
if not image_url.startswith(("http://", "https://")):
|
| 113 |
+
filepath = os.path.join(settings.output_dir, image_url)
|
| 114 |
+
if os.path.exists(filepath):
|
| 115 |
+
return FileResponse(filepath, filename=filename or os.path.basename(filepath))
|
| 116 |
+
raise HTTPException(status_code=404, detail="Image file not found")
|
| 117 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 118 |
+
response = await client.get(image_url)
|
| 119 |
+
response.raise_for_status()
|
| 120 |
+
content_type = response.headers.get("content-type", "image/png")
|
| 121 |
+
if not filename:
|
| 122 |
+
filename = image_url.split("/")[-1].split("?")[0]
|
| 123 |
+
if not filename or "." not in filename:
|
| 124 |
+
filename = "image.png"
|
| 125 |
+
return FastAPIResponse(
|
| 126 |
+
content=response.content,
|
| 127 |
+
media_type=content_type,
|
| 128 |
+
headers={
|
| 129 |
+
"Content-Disposition": f'attachment; filename="{filename}"',
|
| 130 |
+
"Cache-Control": "public, max-age=3600",
|
| 131 |
+
},
|
| 132 |
+
)
|
| 133 |
+
except httpx.HTTPError as e:
|
| 134 |
+
raise HTTPException(status_code=502, detail=f"Failed to fetch image: {str(e)}")
|
| 135 |
+
except Exception as e:
|
| 136 |
+
raise HTTPException(status_code=500, detail=f"Error downloading image: {str(e)}")
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@router.get("/api/models")
|
| 140 |
+
async def list_image_models():
|
| 141 |
+
"""
|
| 142 |
+
List all available image generation models.
|
| 143 |
+
"""
|
| 144 |
+
from services.image import MODEL_REGISTRY
|
| 145 |
+
|
| 146 |
+
preferred_order = ["nano-banana", "nano-banana-pro", "z-image-turbo", "imagen-4-ultra", "recraft-v3", "ideogram-v3", "photon", "seedream-3"]
|
| 147 |
+
models = []
|
| 148 |
+
for key in preferred_order:
|
| 149 |
+
if key in MODEL_REGISTRY:
|
| 150 |
+
config = MODEL_REGISTRY[key]
|
| 151 |
+
models.append({
|
| 152 |
+
"key": key,
|
| 153 |
+
"id": config["id"],
|
| 154 |
+
"uses_dimensions": config.get("uses_dimensions", False),
|
| 155 |
+
})
|
| 156 |
+
for key, config in MODEL_REGISTRY.items():
|
| 157 |
+
if key not in preferred_order:
|
| 158 |
+
models.append({
|
| 159 |
+
"key": key,
|
| 160 |
+
"id": config["id"],
|
| 161 |
+
"uses_dimensions": config.get("uses_dimensions", False),
|
| 162 |
+
})
|
| 163 |
+
models.append({
|
| 164 |
+
"key": "gpt-image-1.5",
|
| 165 |
+
"id": "openai/gpt-image-1.5",
|
| 166 |
+
"uses_dimensions": True,
|
| 167 |
+
})
|
| 168 |
+
return {"models": models, "default": "nano-banana"}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@router.get("/strategies/{niche}")
|
| 172 |
+
async def get_strategies(niche: Literal["home_insurance", "glp1", "auto_insurance"]):
|
| 173 |
+
"""Get available psychological strategies for a niche."""
|
| 174 |
+
from data import home_insurance, glp1, auto_insurance
|
| 175 |
+
|
| 176 |
+
if niche == "home_insurance":
|
| 177 |
+
data = home_insurance.get_niche_data()
|
| 178 |
+
elif niche == "auto_insurance":
|
| 179 |
+
data = auto_insurance.get_niche_data()
|
| 180 |
+
else:
|
| 181 |
+
data = glp1.get_niche_data()
|
| 182 |
+
|
| 183 |
+
strategies = {}
|
| 184 |
+
for name, strategy in data["strategies"].items():
|
| 185 |
+
strategies[name] = {
|
| 186 |
+
"name": strategy["name"],
|
| 187 |
+
"description": strategy["description"],
|
| 188 |
+
"hook_count": len(strategy["hooks"]),
|
| 189 |
+
"sample_hooks": strategy["hooks"][:3],
|
| 190 |
+
}
|
| 191 |
+
return {
|
| 192 |
+
"niche": niche,
|
| 193 |
+
"total_strategies": len(strategies),
|
| 194 |
+
"total_hooks": len(data["all_hooks"]),
|
| 195 |
+
"strategies": strategies,
|
| 196 |
+
}
|
api/routers/info.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Info, root, and health endpoints."""
|
| 2 |
+
|
| 3 |
+
import httpx
|
| 4 |
+
from fastapi import APIRouter
|
| 5 |
+
from fastapi.responses import Response as FastAPIResponse
|
| 6 |
+
|
| 7 |
+
router = APIRouter(tags=["info"])
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@router.get("/api/info")
|
| 11 |
+
async def api_info():
|
| 12 |
+
"""API info endpoint."""
|
| 13 |
+
return {
|
| 14 |
+
"name": "PsyAdGenesis",
|
| 15 |
+
"version": "2.0.0",
|
| 16 |
+
"description": "Design ads that stop the scroll. Generate high-converting ads using Angle × Concept matrix system",
|
| 17 |
+
"endpoints": {
|
| 18 |
+
"POST /generate": "Generate single ad (original mode)",
|
| 19 |
+
"POST /generate/batch": "Generate multiple ads (original mode)",
|
| 20 |
+
"POST /matrix/generate": "Generate ad using Angle × Concept matrix",
|
| 21 |
+
"POST /matrix/testing": "Generate testing matrix (30 combinations)",
|
| 22 |
+
"GET /matrix/angles": "List all 100 angles",
|
| 23 |
+
"GET /matrix/concepts": "List all 100 concepts",
|
| 24 |
+
"GET /matrix/angle/{key}": "Get specific angle details",
|
| 25 |
+
"GET /matrix/concept/{key}": "Get specific concept details",
|
| 26 |
+
"GET /matrix/compatible/{angle_key}": "Get compatible concepts for angle",
|
| 27 |
+
"POST /extensive/generate": "Generate ad using extensive (researcher → creative director → designer → copywriter)",
|
| 28 |
+
"POST /api/motivator/generate": "Generate motivators from niche + angle + concept (Matrix mode)",
|
| 29 |
+
"POST /api/correct": "Correct image for spelling mistakes and visual issues (requires image_id)",
|
| 30 |
+
"POST /api/regenerate": "Regenerate image with optional model selection (requires image_id)",
|
| 31 |
+
"GET /api/models": "List all available image generation models",
|
| 32 |
+
"POST /api/creative/upload": "Upload a creative image for analysis",
|
| 33 |
+
"POST /api/creative/analyze": "Analyze a creative image with AI vision (via URL)",
|
| 34 |
+
"POST /api/creative/analyze/upload": "Analyze a creative image with AI vision (via file upload)",
|
| 35 |
+
"POST /api/creative/modify": "Modify a creative with new angle/concept",
|
| 36 |
+
"GET /api/trends/{niche}": "Get current trending topics from Google News",
|
| 37 |
+
"GET /api/trends/angles/{niche}": "Get auto-generated angles from trending topics",
|
| 38 |
+
"GET /health": "Health check",
|
| 39 |
+
},
|
| 40 |
+
"supported_niches": ["home_insurance", "glp1"],
|
| 41 |
+
"matrix_system": {
|
| 42 |
+
"total_angles": 100,
|
| 43 |
+
"total_concepts": 100,
|
| 44 |
+
"possible_combinations": 10000,
|
| 45 |
+
"formula": "1 Offer → 5-8 Angles → 3-5 Concepts per angle",
|
| 46 |
+
},
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.get("/")
|
| 51 |
+
async def root():
|
| 52 |
+
"""Proxy root to Next.js frontend."""
|
| 53 |
+
try:
|
| 54 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 55 |
+
response = await client.get("http://localhost:3000/")
|
| 56 |
+
return FastAPIResponse(
|
| 57 |
+
content=response.content,
|
| 58 |
+
status_code=response.status_code,
|
| 59 |
+
headers={k: v for k, v in response.headers.items() if k.lower() not in ["content-encoding", "transfer-encoding", "content-length"]},
|
| 60 |
+
media_type=response.headers.get("content-type"),
|
| 61 |
+
)
|
| 62 |
+
except httpx.RequestError:
|
| 63 |
+
return FastAPIResponse(
|
| 64 |
+
content="<html><head><meta http-equiv='refresh' content='2'></head><body><h1>Loading...</h1></body></html>",
|
| 65 |
+
status_code=200,
|
| 66 |
+
media_type="text/html",
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/health")
|
| 71 |
+
async def health():
|
| 72 |
+
"""Health check endpoint for Hugging Face Spaces."""
|
| 73 |
+
return {"status": "ok"}
|
api/routers/matrix.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Angle × Concept matrix endpoints."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 4 |
+
|
| 5 |
+
from api.schemas import (
|
| 6 |
+
MatrixGenerateRequest,
|
| 7 |
+
MatrixGenerateResponse,
|
| 8 |
+
MatrixBatchRequest,
|
| 9 |
+
TestingMatrixResponse,
|
| 10 |
+
RefineCustomRequest,
|
| 11 |
+
RefineCustomResponse,
|
| 12 |
+
)
|
| 13 |
+
from services.generator import ad_generator
|
| 14 |
+
from services.matrix import matrix_service
|
| 15 |
+
from services.auth_dependency import get_current_user
|
| 16 |
+
|
| 17 |
+
router = APIRouter(tags=["matrix"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.post("/matrix/generate", response_model=MatrixGenerateResponse)
|
| 21 |
+
async def generate_with_matrix(
|
| 22 |
+
request: MatrixGenerateRequest,
|
| 23 |
+
username: str = Depends(get_current_user),
|
| 24 |
+
):
|
| 25 |
+
"""
|
| 26 |
+
Generate ad using the Angle × Concept matrix approach.
|
| 27 |
+
Requires authentication. Supports custom angle/concept when key is 'custom'.
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
return await ad_generator.generate_ad_with_matrix(
|
| 31 |
+
niche=request.niche,
|
| 32 |
+
angle_key=request.angle_key,
|
| 33 |
+
concept_key=request.concept_key,
|
| 34 |
+
custom_angle=request.custom_angle,
|
| 35 |
+
custom_concept=request.custom_concept,
|
| 36 |
+
num_images=request.num_images,
|
| 37 |
+
image_model=request.image_model,
|
| 38 |
+
username=username,
|
| 39 |
+
core_motivator=request.core_motivator,
|
| 40 |
+
target_audience=request.target_audience,
|
| 41 |
+
offer=request.offer,
|
| 42 |
+
)
|
| 43 |
+
except Exception as e:
|
| 44 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.post("/matrix/testing", response_model=TestingMatrixResponse)
|
| 48 |
+
async def generate_testing_matrix(request: MatrixBatchRequest):
|
| 49 |
+
"""
|
| 50 |
+
Generate a testing matrix (combinations without images).
|
| 51 |
+
Strategies: balanced, top_performers, diverse.
|
| 52 |
+
"""
|
| 53 |
+
try:
|
| 54 |
+
combinations = matrix_service.generate_testing_matrix(
|
| 55 |
+
niche=request.niche,
|
| 56 |
+
angle_count=request.angle_count,
|
| 57 |
+
concept_count=request.concept_count,
|
| 58 |
+
strategy=request.strategy,
|
| 59 |
+
)
|
| 60 |
+
summary = matrix_service.get_matrix_summary(combinations)
|
| 61 |
+
return {
|
| 62 |
+
"niche": request.niche,
|
| 63 |
+
"strategy": request.strategy,
|
| 64 |
+
"summary": summary,
|
| 65 |
+
"combinations": combinations,
|
| 66 |
+
}
|
| 67 |
+
except Exception as e:
|
| 68 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.get("/matrix/angles")
|
| 72 |
+
async def list_angles():
|
| 73 |
+
"""List all available angles (100 total, 10 categories)."""
|
| 74 |
+
from data.angles import ANGLES, get_all_angles
|
| 75 |
+
|
| 76 |
+
categories = {}
|
| 77 |
+
for cat_key, cat_data in ANGLES.items():
|
| 78 |
+
categories[cat_key.value] = {
|
| 79 |
+
"name": cat_data["name"],
|
| 80 |
+
"angle_count": len(cat_data["angles"]),
|
| 81 |
+
"angles": [
|
| 82 |
+
{"key": a["key"], "name": a["name"], "trigger": a["trigger"], "example": a["example"]}
|
| 83 |
+
for a in cat_data["angles"]
|
| 84 |
+
],
|
| 85 |
+
}
|
| 86 |
+
return {"total_angles": len(get_all_angles()), "categories": categories}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.get("/matrix/concepts")
|
| 90 |
+
async def list_concepts():
|
| 91 |
+
"""List all available concepts (100 total, 10 categories)."""
|
| 92 |
+
from data.concepts import CONCEPTS, get_all_concepts
|
| 93 |
+
|
| 94 |
+
categories = {}
|
| 95 |
+
for cat_key, cat_data in CONCEPTS.items():
|
| 96 |
+
categories[cat_key.value] = {
|
| 97 |
+
"name": cat_data["name"],
|
| 98 |
+
"concept_count": len(cat_data["concepts"]),
|
| 99 |
+
"concepts": [
|
| 100 |
+
{"key": c["key"], "name": c["name"], "structure": c["structure"], "visual": c["visual"]}
|
| 101 |
+
for c in cat_data["concepts"]
|
| 102 |
+
],
|
| 103 |
+
}
|
| 104 |
+
return {"total_concepts": len(get_all_concepts()), "categories": categories}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@router.get("/matrix/angle/{angle_key}")
|
| 108 |
+
async def get_angle(angle_key: str):
|
| 109 |
+
"""Get details for a specific angle by key."""
|
| 110 |
+
from data.angles import get_angle_by_key
|
| 111 |
+
|
| 112 |
+
angle = get_angle_by_key(angle_key)
|
| 113 |
+
if not angle:
|
| 114 |
+
raise HTTPException(status_code=404, detail=f"Angle '{angle_key}' not found")
|
| 115 |
+
return angle
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@router.get("/matrix/concept/{concept_key}")
|
| 119 |
+
async def get_concept(concept_key: str):
|
| 120 |
+
"""Get details for a specific concept by key."""
|
| 121 |
+
from data.concepts import get_concept_by_key
|
| 122 |
+
|
| 123 |
+
concept = get_concept_by_key(concept_key)
|
| 124 |
+
if not concept:
|
| 125 |
+
raise HTTPException(status_code=404, detail=f"Concept '{concept_key}' not found")
|
| 126 |
+
return concept
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.get("/matrix/compatible/{angle_key}")
|
| 130 |
+
async def get_compatible_concepts(angle_key: str):
|
| 131 |
+
"""Get concepts compatible with a specific angle."""
|
| 132 |
+
from data.angles import get_angle_by_key
|
| 133 |
+
from data.concepts import get_compatible_concepts as get_compatible
|
| 134 |
+
|
| 135 |
+
angle = get_angle_by_key(angle_key)
|
| 136 |
+
if not angle:
|
| 137 |
+
raise HTTPException(status_code=404, detail=f"Angle '{angle_key}' not found")
|
| 138 |
+
compatible = get_compatible(angle.get("trigger", ""))
|
| 139 |
+
return {
|
| 140 |
+
"angle": {"key": angle["key"], "name": angle["name"], "trigger": angle["trigger"]},
|
| 141 |
+
"compatible_concepts": [
|
| 142 |
+
{"key": c["key"], "name": c["name"], "structure": c["structure"]}
|
| 143 |
+
for c in compatible
|
| 144 |
+
],
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.post("/matrix/refine-custom", response_model=RefineCustomResponse)
|
| 149 |
+
async def refine_custom_angle_or_concept(request: RefineCustomRequest):
|
| 150 |
+
"""Refine a custom angle or concept text using AI."""
|
| 151 |
+
try:
|
| 152 |
+
result = await ad_generator.refine_custom_angle_or_concept(
|
| 153 |
+
text=request.text,
|
| 154 |
+
type=request.type,
|
| 155 |
+
niche=request.niche,
|
| 156 |
+
goal=request.goal,
|
| 157 |
+
)
|
| 158 |
+
return {"status": "success", "type": request.type, "refined": result}
|
| 159 |
+
except Exception as e:
|
| 160 |
+
return {"status": "error", "type": request.type, "refined": None, "error": str(e)}
|
api/routers/motivator.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Motivator generation endpoint."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 4 |
+
|
| 5 |
+
from api.schemas import MotivatorGenerateRequest, MotivatorGenerateResponse
|
| 6 |
+
from services.motivator import generate_motivators as motivator_generate
|
| 7 |
+
from services.auth_dependency import get_current_user
|
| 8 |
+
|
| 9 |
+
router = APIRouter(tags=["motivator"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.post("/api/motivator/generate", response_model=MotivatorGenerateResponse)
|
| 13 |
+
async def motivator_generate_endpoint(
|
| 14 |
+
request: MotivatorGenerateRequest,
|
| 15 |
+
username: str = Depends(get_current_user),
|
| 16 |
+
):
|
| 17 |
+
"""
|
| 18 |
+
Generate motivators from niche + angle + concept context (Matrix mode).
|
| 19 |
+
Requires authentication.
|
| 20 |
+
"""
|
| 21 |
+
try:
|
| 22 |
+
motivators = await motivator_generate(
|
| 23 |
+
niche=request.niche,
|
| 24 |
+
angle=request.angle,
|
| 25 |
+
concept=request.concept,
|
| 26 |
+
target_audience=request.target_audience,
|
| 27 |
+
offer=request.offer,
|
| 28 |
+
count=request.count,
|
| 29 |
+
)
|
| 30 |
+
return {"motivators": motivators}
|
| 31 |
+
except Exception as e:
|
| 32 |
+
raise HTTPException(status_code=500, detail=str(e))
|
api/routers/trends.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Trending topics endpoints."""
|
| 2 |
+
|
| 3 |
+
from typing import Literal
|
| 4 |
+
from fastapi import APIRouter, Depends
|
| 5 |
+
|
| 6 |
+
from services.auth_dependency import get_current_user
|
| 7 |
+
from services.trend_monitor import trend_monitor
|
| 8 |
+
from services.current_occasions import get_current_occasions
|
| 9 |
+
|
| 10 |
+
router = APIRouter(tags=["trends"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/api/trends/{niche}")
|
| 14 |
+
async def get_trends(
|
| 15 |
+
niche: Literal["home_insurance", "glp1", "auto_insurance"],
|
| 16 |
+
username: str = Depends(get_current_user),
|
| 17 |
+
):
|
| 18 |
+
"""
|
| 19 |
+
Get current trending topics for a niche: date-based occasions (e.g. Valentine's Week)
|
| 20 |
+
plus niche-specific news. Topics are analyzed from the current date so they stay timely.
|
| 21 |
+
"""
|
| 22 |
+
try:
|
| 23 |
+
data = await trend_monitor.get_relevant_trends_for_niche(niche)
|
| 24 |
+
raw = data.get("relevant_trends") or []
|
| 25 |
+
# Map to frontend shape: title, description (from summary), category, url
|
| 26 |
+
trends = [
|
| 27 |
+
{
|
| 28 |
+
"title": t.get("title", ""),
|
| 29 |
+
"description": t.get("summary", t.get("description", "")),
|
| 30 |
+
"category": t.get("category", "General"),
|
| 31 |
+
"url": t.get("url"),
|
| 32 |
+
}
|
| 33 |
+
for t in raw
|
| 34 |
+
]
|
| 35 |
+
return {
|
| 36 |
+
"status": "ok",
|
| 37 |
+
"niche": niche,
|
| 38 |
+
"trends": trends,
|
| 39 |
+
"count": len(trends),
|
| 40 |
+
}
|
| 41 |
+
except Exception as e:
|
| 42 |
+
# Fallback to occasions only if news fails
|
| 43 |
+
occasions = await get_current_occasions()
|
| 44 |
+
trends = [
|
| 45 |
+
{
|
| 46 |
+
"title": o["title"],
|
| 47 |
+
"description": o["summary"],
|
| 48 |
+
"category": o.get("category", "Occasion"),
|
| 49 |
+
}
|
| 50 |
+
for o in occasions
|
| 51 |
+
]
|
| 52 |
+
return {
|
| 53 |
+
"status": "ok",
|
| 54 |
+
"niche": niche,
|
| 55 |
+
"trends": trends,
|
| 56 |
+
"count": len(trends),
|
| 57 |
+
"message": "Showing current occasions (news temporarily unavailable).",
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.get("/api/trends/angles/{niche}")
|
| 62 |
+
async def get_trending_angles(
|
| 63 |
+
niche: Literal["home_insurance", "glp1", "auto_insurance"],
|
| 64 |
+
username: str = Depends(get_current_user),
|
| 65 |
+
):
|
| 66 |
+
"""
|
| 67 |
+
Get auto-generated angle suggestions based on current trends and occasions.
|
| 68 |
+
"""
|
| 69 |
+
try:
|
| 70 |
+
angles = await trend_monitor.get_trending_angles(niche)
|
| 71 |
+
return {
|
| 72 |
+
"status": "ok",
|
| 73 |
+
"niche": niche,
|
| 74 |
+
"trending_angles": angles,
|
| 75 |
+
"count": len(angles),
|
| 76 |
+
}
|
| 77 |
+
except Exception as e:
|
| 78 |
+
return {
|
| 79 |
+
"status": "ok",
|
| 80 |
+
"niche": niche,
|
| 81 |
+
"trending_angles": [],
|
| 82 |
+
"count": 0,
|
| 83 |
+
"message": "Angle suggestions temporarily unavailable.",
|
| 84 |
+
}
|
api/schemas.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Request/response schemas for PsyAdGenesis API.
|
| 3 |
+
Keeps all Pydantic models in one place for consistency and reuse.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
from typing import Optional, List, Literal, Any, Dict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# ----- Generate -----
|
| 11 |
+
class GenerateRequest(BaseModel):
|
| 12 |
+
"""Request schema for ad generation."""
|
| 13 |
+
niche: Literal["home_insurance", "glp1", "auto_insurance"] = Field(
|
| 14 |
+
description="Target niche: home_insurance, glp1, or auto_insurance"
|
| 15 |
+
)
|
| 16 |
+
num_images: int = Field(default=1, ge=1, le=10, description="Number of images to generate (1-10)")
|
| 17 |
+
image_model: Optional[str] = Field(default=None, description="Image generation model to use")
|
| 18 |
+
target_audience: Optional[str] = Field(default=None, description="Optional target audience description")
|
| 19 |
+
offer: Optional[str] = Field(default=None, description="Optional offer to run")
|
| 20 |
+
use_trending: bool = Field(default=False, description="Whether to incorporate current trending topics")
|
| 21 |
+
trending_context: Optional[str] = Field(default=None, description="Specific trending context when use_trending=True")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class GenerateBatchRequest(BaseModel):
|
| 25 |
+
"""Request schema for batch ad generation."""
|
| 26 |
+
niche: Literal["home_insurance", "glp1", "auto_insurance"] = Field(description="Target niche")
|
| 27 |
+
count: int = Field(default=5, ge=1, le=100, description="Number of ads to generate (1-100)")
|
| 28 |
+
images_per_ad: int = Field(default=1, ge=1, le=3, description="Images per ad (1-3)")
|
| 29 |
+
image_model: Optional[str] = Field(default=None, description="Image generation model to use")
|
| 30 |
+
method: Optional[Literal["standard", "matrix"]] = Field(default=None, description="Generation method or None for mixed")
|
| 31 |
+
target_audience: Optional[str] = Field(default=None, description="Optional target audience")
|
| 32 |
+
offer: Optional[str] = Field(default=None, description="Optional offer to run")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ImageResult(BaseModel):
|
| 36 |
+
"""Image result schema."""
|
| 37 |
+
filename: Optional[str] = None
|
| 38 |
+
filepath: Optional[str] = None
|
| 39 |
+
image_url: Optional[str] = None
|
| 40 |
+
model_used: Optional[str] = None
|
| 41 |
+
seed: Optional[int] = None
|
| 42 |
+
error: Optional[str] = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class AdMetadata(BaseModel):
|
| 46 |
+
"""Metadata about the generation."""
|
| 47 |
+
strategies_used: List[str]
|
| 48 |
+
creative_direction: str
|
| 49 |
+
visual_mood: str
|
| 50 |
+
framework: Optional[str] = None
|
| 51 |
+
camera_angle: Optional[str] = None
|
| 52 |
+
lighting: Optional[str] = None
|
| 53 |
+
composition: Optional[str] = None
|
| 54 |
+
hooks_inspiration: List[str]
|
| 55 |
+
visual_styles: List[str]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class GenerateResponse(BaseModel):
|
| 59 |
+
"""Response schema for ad generation."""
|
| 60 |
+
id: str
|
| 61 |
+
niche: str
|
| 62 |
+
created_at: str
|
| 63 |
+
title: Optional[str] = Field(default=None, description="Short punchy ad title (3-5 words)")
|
| 64 |
+
headline: str
|
| 65 |
+
primary_text: str
|
| 66 |
+
description: str
|
| 67 |
+
body_story: str = Field(description="Compelling 8-12 sentence story that hooks emotionally")
|
| 68 |
+
cta: str
|
| 69 |
+
psychological_angle: str
|
| 70 |
+
why_it_works: Optional[str] = None
|
| 71 |
+
images: List[ImageResult]
|
| 72 |
+
metadata: AdMetadata
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class BatchResponse(BaseModel):
|
| 76 |
+
"""Response schema for batch generation."""
|
| 77 |
+
count: int
|
| 78 |
+
ads: List[GenerateResponse]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ----- Matrix -----
|
| 82 |
+
class MatrixGenerateRequest(BaseModel):
|
| 83 |
+
"""Request for angle × concept matrix generation."""
|
| 84 |
+
niche: Literal["home_insurance", "glp1", "auto_insurance"] = Field(description="Target niche")
|
| 85 |
+
angle_key: Optional[str] = Field(default=None, description="Specific angle key (random if not provided)")
|
| 86 |
+
concept_key: Optional[str] = Field(default=None, description="Specific concept key (random if not provided)")
|
| 87 |
+
custom_angle: Optional[str] = Field(default=None, description="Custom angle text when angle_key is 'custom'")
|
| 88 |
+
custom_concept: Optional[str] = Field(default=None, description="Custom concept text when concept_key is 'custom'")
|
| 89 |
+
num_images: int = Field(default=1, ge=1, le=5, description="Number of images to generate")
|
| 90 |
+
image_model: Optional[str] = Field(default=None, description="Image generation model to use")
|
| 91 |
+
target_audience: Optional[str] = Field(default=None, description="Optional target audience")
|
| 92 |
+
offer: Optional[str] = Field(default=None, description="Optional offer to run")
|
| 93 |
+
core_motivator: Optional[str] = Field(default=None, description="Optional motivator to guide generation")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class RefineCustomRequest(BaseModel):
|
| 97 |
+
"""Request to refine custom angle or concept text using AI."""
|
| 98 |
+
text: str = Field(description="The raw custom text from user")
|
| 99 |
+
type: Literal["angle", "concept"] = Field(description="Whether this is an angle or concept")
|
| 100 |
+
niche: Literal["home_insurance", "glp1", "auto_insurance"] = Field(description="Target niche for context")
|
| 101 |
+
goal: Optional[str] = Field(default=None, description="Optional user goal or context")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class RefinedAngleResponse(BaseModel):
|
| 105 |
+
"""Response for refined angle."""
|
| 106 |
+
key: str = Field(default="custom")
|
| 107 |
+
name: str
|
| 108 |
+
trigger: str
|
| 109 |
+
example: str
|
| 110 |
+
category: str = Field(default="Custom")
|
| 111 |
+
original_text: str
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class RefinedConceptResponse(BaseModel):
|
| 115 |
+
"""Response for refined concept."""
|
| 116 |
+
key: str = Field(default="custom")
|
| 117 |
+
name: str
|
| 118 |
+
structure: str
|
| 119 |
+
visual: str
|
| 120 |
+
category: str = Field(default="Custom")
|
| 121 |
+
original_text: str
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class RefineCustomResponse(BaseModel):
|
| 125 |
+
"""Response for refined custom angle or concept."""
|
| 126 |
+
status: str
|
| 127 |
+
type: Literal["angle", "concept"]
|
| 128 |
+
refined: Optional[dict] = None
|
| 129 |
+
error: Optional[str] = None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class MotivatorGenerateRequest(BaseModel):
|
| 133 |
+
"""Request to generate motivators from niche + angle + concept."""
|
| 134 |
+
niche: Literal["home_insurance", "glp1", "auto_insurance"] = Field(description="Target niche")
|
| 135 |
+
angle: Dict[str, Any] = Field(description="Angle context: name, trigger, example")
|
| 136 |
+
concept: Dict[str, Any] = Field(description="Concept context: name, structure, visual")
|
| 137 |
+
target_audience: Optional[str] = Field(default=None, description="Optional target audience")
|
| 138 |
+
offer: Optional[str] = Field(default=None, description="Optional offer")
|
| 139 |
+
count: int = Field(default=6, ge=3, le=10, description="Number of motivators to generate")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class MotivatorGenerateResponse(BaseModel):
|
| 143 |
+
"""Response with generated motivators."""
|
| 144 |
+
motivators: List[str]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class MatrixBatchRequest(BaseModel):
|
| 148 |
+
"""Request for batch matrix generation."""
|
| 149 |
+
niche: Literal["home_insurance", "glp1"] = Field(description="Target niche")
|
| 150 |
+
angle_count: int = Field(default=6, ge=1, le=10, description="Number of angles to test")
|
| 151 |
+
concept_count: int = Field(default=5, ge=1, le=10, description="Number of concepts per angle")
|
| 152 |
+
strategy: Literal["balanced", "top_performers", "diverse"] = Field(default="balanced", description="Selection strategy")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class AngleInfo(BaseModel):
|
| 156 |
+
"""Angle information."""
|
| 157 |
+
key: str
|
| 158 |
+
name: str
|
| 159 |
+
trigger: str
|
| 160 |
+
category: str
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class ConceptInfo(BaseModel):
|
| 164 |
+
"""Concept information."""
|
| 165 |
+
key: str
|
| 166 |
+
name: str
|
| 167 |
+
structure: str
|
| 168 |
+
visual: str
|
| 169 |
+
category: str
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class MatrixMetadata(BaseModel):
|
| 173 |
+
"""Matrix generation metadata."""
|
| 174 |
+
generation_method: str = "angle_concept_matrix"
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class MatrixResult(BaseModel):
|
| 178 |
+
"""Result from matrix-based generation."""
|
| 179 |
+
angle: AngleInfo
|
| 180 |
+
concept: ConceptInfo
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
class MatrixGenerateResponse(BaseModel):
|
| 184 |
+
"""Response for matrix-based ad generation."""
|
| 185 |
+
id: str
|
| 186 |
+
niche: str
|
| 187 |
+
created_at: str
|
| 188 |
+
title: Optional[str] = Field(default=None, description="Short punchy ad title")
|
| 189 |
+
headline: str
|
| 190 |
+
primary_text: str
|
| 191 |
+
description: str
|
| 192 |
+
body_story: str = Field(description="Compelling 8-12 sentence story that hooks emotionally")
|
| 193 |
+
cta: str
|
| 194 |
+
psychological_angle: str
|
| 195 |
+
why_it_works: Optional[str] = None
|
| 196 |
+
images: List[ImageResult]
|
| 197 |
+
matrix: MatrixResult
|
| 198 |
+
metadata: MatrixMetadata
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class CombinationInfo(BaseModel):
|
| 202 |
+
"""Info about a single angle × concept combination."""
|
| 203 |
+
combination_id: str
|
| 204 |
+
angle: AngleInfo
|
| 205 |
+
concept: ConceptInfo
|
| 206 |
+
compatibility_score: float
|
| 207 |
+
prompt_guidance: str
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
class MatrixSummary(BaseModel):
|
| 211 |
+
"""Summary of a testing matrix."""
|
| 212 |
+
total_combinations: int
|
| 213 |
+
unique_angles: int
|
| 214 |
+
unique_concepts: int
|
| 215 |
+
average_compatibility: float
|
| 216 |
+
angles_used: List[str]
|
| 217 |
+
concepts_used: List[str]
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class TestingMatrixResponse(BaseModel):
|
| 221 |
+
"""Response for testing matrix generation."""
|
| 222 |
+
niche: str
|
| 223 |
+
strategy: str
|
| 224 |
+
summary: MatrixSummary
|
| 225 |
+
combinations: List[CombinationInfo]
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ----- Auth -----
|
| 229 |
+
class LoginRequest(BaseModel):
|
| 230 |
+
"""Login request."""
|
| 231 |
+
username: str = Field(description="Username")
|
| 232 |
+
password: str = Field(description="Password")
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class LoginResponse(BaseModel):
|
| 236 |
+
"""Login response."""
|
| 237 |
+
token: str
|
| 238 |
+
username: str
|
| 239 |
+
message: str = "Login successful"
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ----- Correction -----
|
| 243 |
+
class ImageCorrectRequest(BaseModel):
|
| 244 |
+
"""Request schema for image correction."""
|
| 245 |
+
image_id: str = Field(description="ID of existing ad creative or 'temp-id' for images not in DB")
|
| 246 |
+
image_url: Optional[str] = Field(default=None, description="Optional image URL when image_id='temp-id'")
|
| 247 |
+
user_instructions: Optional[str] = Field(default=None, description="User instructions for correction")
|
| 248 |
+
auto_analyze: bool = Field(default=False, description="Auto-analyze image for issues if no instructions")
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
class SpellingCorrection(BaseModel):
|
| 252 |
+
"""Spelling correction entry."""
|
| 253 |
+
detected: str
|
| 254 |
+
corrected: str
|
| 255 |
+
context: Optional[str] = None
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
class VisualCorrection(BaseModel):
|
| 259 |
+
"""Visual correction entry."""
|
| 260 |
+
issue: str
|
| 261 |
+
suggestion: str
|
| 262 |
+
priority: Optional[str] = None
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
class CorrectionData(BaseModel):
|
| 266 |
+
"""Correction data structure."""
|
| 267 |
+
spelling_corrections: List[SpellingCorrection]
|
| 268 |
+
visual_corrections: List[VisualCorrection]
|
| 269 |
+
corrected_prompt: str
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
class CorrectedImageResult(BaseModel):
|
| 273 |
+
"""Corrected image result."""
|
| 274 |
+
filename: Optional[str] = None
|
| 275 |
+
filepath: Optional[str] = None
|
| 276 |
+
image_url: Optional[str] = None
|
| 277 |
+
r2_url: Optional[str] = None
|
| 278 |
+
model_used: Optional[str] = None
|
| 279 |
+
corrected_prompt: Optional[str] = None
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
class ImageCorrectResponse(BaseModel):
|
| 283 |
+
"""Response schema for image correction."""
|
| 284 |
+
status: str
|
| 285 |
+
analysis: Optional[str] = None
|
| 286 |
+
corrections: Optional[CorrectionData] = None
|
| 287 |
+
corrected_image: Optional[CorrectedImageResult] = None
|
| 288 |
+
error: Optional[str] = None
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
class ImageRegenerateRequest(BaseModel):
|
| 292 |
+
"""Request schema for image regeneration."""
|
| 293 |
+
image_id: str = Field(description="ID of existing ad creative in database")
|
| 294 |
+
image_model: Optional[str] = Field(default=None, description="Image model to use (or original if not provided)")
|
| 295 |
+
preview_only: bool = Field(default=True, description="If True, preview only; user confirms selection later")
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
class RegeneratedImageResult(BaseModel):
|
| 299 |
+
"""Regenerated image result."""
|
| 300 |
+
filename: Optional[str] = None
|
| 301 |
+
filepath: Optional[str] = None
|
| 302 |
+
image_url: Optional[str] = None
|
| 303 |
+
r2_url: Optional[str] = None
|
| 304 |
+
model_used: Optional[str] = None
|
| 305 |
+
prompt_used: Optional[str] = None
|
| 306 |
+
seed_used: Optional[int] = None
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
class ImageRegenerateResponse(BaseModel):
|
| 310 |
+
"""Response schema for image regeneration."""
|
| 311 |
+
status: str
|
| 312 |
+
regenerated_image: Optional[RegeneratedImageResult] = None
|
| 313 |
+
original_image_url: Optional[str] = None
|
| 314 |
+
original_preserved: bool = Field(default=True, description="Whether original image info was preserved")
|
| 315 |
+
is_preview: bool = Field(default=False, description="Whether this is a preview (not yet saved)")
|
| 316 |
+
error: Optional[str] = None
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
class ImageSelectionRequest(BaseModel):
|
| 320 |
+
"""Request schema for confirming image selection after regeneration."""
|
| 321 |
+
image_id: str = Field(description="ID of existing ad creative in database")
|
| 322 |
+
selection: str = Field(description="Which image to keep: 'new' or 'original'")
|
| 323 |
+
new_image_url: Optional[str] = Field(default=None, description="URL of new image (required if selection='new')")
|
| 324 |
+
new_r2_url: Optional[str] = Field(default=None, description="R2 URL of the new image")
|
| 325 |
+
new_filename: Optional[str] = Field(default=None, description="Filename of the new image")
|
| 326 |
+
new_model: Optional[str] = Field(default=None, description="Model used for the new image")
|
| 327 |
+
new_seed: Optional[int] = Field(default=None, description="Seed used for the new image")
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# ----- Extensive -----
|
| 331 |
+
class ExtensiveGenerateRequest(BaseModel):
|
| 332 |
+
"""Request for extensive generation."""
|
| 333 |
+
niche: str = Field(description="Target niche or 'others' with custom_niche")
|
| 334 |
+
custom_niche: Optional[str] = Field(default=None, description="Custom niche when 'others' is selected")
|
| 335 |
+
target_audience: Optional[str] = Field(default=None, description="Optional target audience")
|
| 336 |
+
offer: Optional[str] = Field(default=None, description="Optional offer to run")
|
| 337 |
+
num_images: int = Field(default=1, ge=1, le=3, description="Number of images per strategy (1-3)")
|
| 338 |
+
image_model: Optional[str] = Field(default=None, description="Image generation model to use")
|
| 339 |
+
num_strategies: int = Field(default=5, ge=1, le=10, description="Number of creative strategies (1-10)")
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
class ExtensiveJobResponse(BaseModel):
|
| 343 |
+
"""Response when extensive generation is started (202 Accepted)."""
|
| 344 |
+
job_id: str
|
| 345 |
+
message: str = "Extensive generation started. Poll /extensive/status/{job_id} for progress."
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
# ----- Creative (upload / analyze / modify) -----
|
| 349 |
+
class CreativeAnalysisData(BaseModel):
|
| 350 |
+
"""Structured analysis of a creative."""
|
| 351 |
+
visual_style: str
|
| 352 |
+
color_palette: List[str]
|
| 353 |
+
mood: str
|
| 354 |
+
composition: str
|
| 355 |
+
subject_matter: str
|
| 356 |
+
text_content: Optional[str] = None
|
| 357 |
+
current_angle: Optional[str] = None
|
| 358 |
+
current_concept: Optional[str] = None
|
| 359 |
+
target_audience: Optional[str] = None
|
| 360 |
+
strengths: List[str]
|
| 361 |
+
areas_for_improvement: List[str]
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
class CreativeAnalyzeRequest(BaseModel):
|
| 365 |
+
"""Request for creative analysis."""
|
| 366 |
+
image_url: Optional[str] = Field(default=None, description="URL of the image to analyze (alternative to file upload)")
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
class CreativeAnalysisResponse(BaseModel):
|
| 370 |
+
"""Response for creative analysis."""
|
| 371 |
+
status: str
|
| 372 |
+
analysis: Optional[CreativeAnalysisData] = None
|
| 373 |
+
suggested_angles: Optional[List[str]] = None
|
| 374 |
+
suggested_concepts: Optional[List[str]] = None
|
| 375 |
+
error: Optional[str] = None
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
class CreativeModifyRequest(BaseModel):
|
| 379 |
+
"""Request for creative modification."""
|
| 380 |
+
image_url: str = Field(description="URL of the original image")
|
| 381 |
+
analysis: Optional[Dict[str, Any]] = Field(default=None, description="Previous analysis data (optional)")
|
| 382 |
+
angle: Optional[str] = Field(default=None, description="Angle to apply to the creative")
|
| 383 |
+
concept: Optional[str] = Field(default=None, description="Concept to apply to the creative")
|
| 384 |
+
mode: Literal["modify", "inspired"] = Field(default="modify", description="modify = image-to-image, inspired = new generation")
|
| 385 |
+
image_model: Optional[str] = Field(default=None, description="Image generation model to use")
|
| 386 |
+
user_prompt: Optional[str] = Field(default=None, description="Optional custom user prompt for modification")
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
class ModifiedImageResult(BaseModel):
|
| 390 |
+
"""Result of creative modification."""
|
| 391 |
+
filename: Optional[str] = None
|
| 392 |
+
filepath: Optional[str] = None
|
| 393 |
+
image_url: Optional[str] = None
|
| 394 |
+
r2_url: Optional[str] = None
|
| 395 |
+
model_used: Optional[str] = None
|
| 396 |
+
mode: Optional[str] = None
|
| 397 |
+
applied_angle: Optional[str] = None
|
| 398 |
+
applied_concept: Optional[str] = None
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
class CreativeModifyResponse(BaseModel):
|
| 402 |
+
"""Response for creative modification."""
|
| 403 |
+
status: str
|
| 404 |
+
prompt: Optional[str] = None
|
| 405 |
+
image: Optional[ModifiedImageResult] = None
|
| 406 |
+
error: Optional[str] = None
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
class FileUploadResponse(BaseModel):
|
| 410 |
+
"""Response for file upload."""
|
| 411 |
+
status: str
|
| 412 |
+
image_url: Optional[str] = None
|
| 413 |
+
filename: Optional[str] = None
|
| 414 |
+
error: Optional[str] = None
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
# ----- Database -----
|
| 418 |
+
class AdCreativeDB(BaseModel):
|
| 419 |
+
"""Ad creative from database."""
|
| 420 |
+
id: str
|
| 421 |
+
niche: str
|
| 422 |
+
title: Optional[str] = None
|
| 423 |
+
headline: str
|
| 424 |
+
primary_text: Optional[str] = None
|
| 425 |
+
description: Optional[str] = None
|
| 426 |
+
body_story: Optional[str] = None
|
| 427 |
+
cta: Optional[str] = None
|
| 428 |
+
psychological_angle: Optional[str] = None
|
| 429 |
+
why_it_works: Optional[str] = None
|
| 430 |
+
image_url: Optional[str] = None
|
| 431 |
+
image_filename: Optional[str] = None
|
| 432 |
+
image_model: Optional[str] = None
|
| 433 |
+
image_seed: Optional[int] = None
|
| 434 |
+
angle_key: Optional[str] = None
|
| 435 |
+
angle_name: Optional[str] = None
|
| 436 |
+
concept_key: Optional[str] = None
|
| 437 |
+
concept_name: Optional[str] = None
|
| 438 |
+
generation_method: Optional[str] = None
|
| 439 |
+
created_at: Optional[str] = None
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
class DbStatsResponse(BaseModel):
|
| 443 |
+
"""Database statistics response."""
|
| 444 |
+
connected: bool
|
| 445 |
+
total_ads: Optional[int] = None
|
| 446 |
+
by_niche: Optional[Dict[str, int]] = None
|
| 447 |
+
by_method: Optional[Dict[str, int]] = None
|
| 448 |
+
error: Optional[str] = None
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
class EditAdCopyRequest(BaseModel):
|
| 452 |
+
"""Request for editing ad copy."""
|
| 453 |
+
ad_id: str = Field(description="ID of the ad to edit")
|
| 454 |
+
field: Literal["title", "headline", "primary_text", "description", "body_story", "cta"] = Field(description="Field to edit")
|
| 455 |
+
value: str = Field(description="New value (manual) or current value (AI edit)")
|
| 456 |
+
mode: Literal["manual", "ai"] = Field(description="Edit mode: manual or ai")
|
| 457 |
+
user_suggestion: Optional[str] = Field(default=None, description="User suggestion for AI editing (optional)")
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
# ----- Export -----
|
| 461 |
+
class BulkExportRequest(BaseModel):
|
| 462 |
+
"""Request schema for bulk export."""
|
| 463 |
+
ad_ids: List[str] = Field(description="List of ad IDs to export", min_length=1, max_length=50)
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
class BulkExportResponse(BaseModel):
|
| 467 |
+
"""Response schema for bulk export (actual response is FileResponse with ZIP)."""
|
| 468 |
+
status: str
|
| 469 |
+
message: str
|
| 470 |
+
filename: str
|
data/frameworks.py
CHANGED
|
@@ -901,79 +901,6 @@ FRAMEWORKS: Dict[str, Dict[str, Any]] = {
|
|
| 901 |
|
| 902 |
}
|
| 903 |
|
| 904 |
-
# Framework examples by niche
|
| 905 |
-
NICHE_FRAMEWORK_EXAMPLES: Dict[str, Dict[str, List[str]]] = {
|
| 906 |
-
"home_insurance": {
|
| 907 |
-
"breaking_news": [
|
| 908 |
-
"BREAKING: Home Insurance Rates Drop 40%",
|
| 909 |
-
"ALERT: New Homeowner Discounts Available",
|
| 910 |
-
"URGENT: Rate Freeze Ends Friday",
|
| 911 |
-
],
|
| 912 |
-
"mobile_post": [
|
| 913 |
-
"Protect Your Home in 3 Minutes",
|
| 914 |
-
"One Quote. Big Savings.",
|
| 915 |
-
"Tap to See Your Rate",
|
| 916 |
-
],
|
| 917 |
-
"before_after": [
|
| 918 |
-
"Before: $2,400/year. After: $1,200/year",
|
| 919 |
-
"Old policy vs New savings",
|
| 920 |
-
"What switching saved me",
|
| 921 |
-
],
|
| 922 |
-
"testimonial": [
|
| 923 |
-
'"I saved $1,200 on my first year"',
|
| 924 |
-
"Join 100,000+ protected homeowners",
|
| 925 |
-
"Rated #1 by customers like you",
|
| 926 |
-
],
|
| 927 |
-
"problem_solution": [
|
| 928 |
-
"Worried your home isn't covered?",
|
| 929 |
-
"Stop overpaying for insurance",
|
| 930 |
-
"End the coverage gaps",
|
| 931 |
-
],
|
| 932 |
-
},
|
| 933 |
-
"glp1": {
|
| 934 |
-
"breaking_news": [
|
| 935 |
-
"NEW: FDA-Approved Weight Loss Solution",
|
| 936 |
-
"ALERT: Limited Appointments Available",
|
| 937 |
-
"EXCLUSIVE: Online Consultations Now Open",
|
| 938 |
-
],
|
| 939 |
-
"before_after": [
|
| 940 |
-
"Her 60-Day Transformation",
|
| 941 |
-
"What Changed in 90 Days",
|
| 942 |
-
"The Results Speak for Themselves",
|
| 943 |
-
],
|
| 944 |
-
"testimonial": [
|
| 945 |
-
'"I finally found what works"',
|
| 946 |
-
"Thousands have transformed",
|
| 947 |
-
"Real patients. Real results.",
|
| 948 |
-
],
|
| 949 |
-
"lifestyle": [
|
| 950 |
-
"Feel Confident Again",
|
| 951 |
-
"The Energy to Live Fully",
|
| 952 |
-
"Your New Chapter Starts Here",
|
| 953 |
-
],
|
| 954 |
-
"authority": [
|
| 955 |
-
"Doctor-Recommended Solution",
|
| 956 |
-
"Clinically Proven Results",
|
| 957 |
-
"Backed by Medical Research",
|
| 958 |
-
],
|
| 959 |
-
"scarcity": [
|
| 960 |
-
"Only 20 Appointments This Week",
|
| 961 |
-
"Limited Spots Available",
|
| 962 |
-
"Join the Waitlist Now",
|
| 963 |
-
],
|
| 964 |
-
"risk_reversal": [
|
| 965 |
-
"100% Satisfaction Guarantee",
|
| 966 |
-
"Try Risk-Free for 30 Days",
|
| 967 |
-
"No Commitment Required",
|
| 968 |
-
],
|
| 969 |
-
"case_study": [
|
| 970 |
-
"How Maria Lost 30 Pounds in 3 Months",
|
| 971 |
-
"Real Patient Results",
|
| 972 |
-
"The Transformation Journey",
|
| 973 |
-
],
|
| 974 |
-
},
|
| 975 |
-
}
|
| 976 |
-
|
| 977 |
|
| 978 |
def get_all_frameworks() -> Dict[str, Dict[str, Any]]:
|
| 979 |
"""Get all available frameworks."""
|
|
@@ -998,35 +925,26 @@ def get_frameworks_for_niche(niche: str, count: int = 3) -> List[Dict[str, Any]]
|
|
| 998 |
# Niche-specific framework preferences
|
| 999 |
niche_preferences = {
|
| 1000 |
"home_insurance": ["testimonial", "problem_solution", "authority", "before_after", "lifestyle"],
|
| 1001 |
-
"glp1": ["
|
| 1002 |
"auto_insurance": ["testimonial", "problem_solution", "authority", "before_after", "comparison"],
|
| 1003 |
}
|
| 1004 |
|
| 1005 |
# Get preferred frameworks or use all
|
| 1006 |
preferred_keys = niche_preferences.get(niche_lower, list(FRAMEWORKS.keys()))
|
| 1007 |
|
| 1008 |
-
#
|
| 1009 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1010 |
|
| 1011 |
-
#
|
| 1012 |
selected = all_keys[:count]
|
| 1013 |
-
random.shuffle(selected)
|
| 1014 |
|
| 1015 |
return [{"key": k, **FRAMEWORKS[k]} for k in selected]
|
| 1016 |
|
| 1017 |
|
| 1018 |
-
def get_framework_hook_examples(framework_key: str, niche: Optional[str] = None) -> List[str]:
|
| 1019 |
-
"""Get hook examples for a framework, optionally niche-specific."""
|
| 1020 |
-
if niche:
|
| 1021 |
-
niche_key = niche.lower().replace(" ", "_").replace("-", "_")
|
| 1022 |
-
niche_examples = NICHE_FRAMEWORK_EXAMPLES.get(niche_key, {}).get(framework_key, [])
|
| 1023 |
-
if niche_examples:
|
| 1024 |
-
return niche_examples
|
| 1025 |
-
|
| 1026 |
-
framework = FRAMEWORKS.get(framework_key)
|
| 1027 |
-
return framework.get("hook_examples", []) if framework else []
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
# ---------------------------------------------------------------------------
|
| 1031 |
# Container-type framework helpers (visual format / "container" = framework with container_type=True)
|
| 1032 |
# ---------------------------------------------------------------------------
|
|
|
|
| 901 |
|
| 902 |
}
|
| 903 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
|
| 905 |
def get_all_frameworks() -> Dict[str, Dict[str, Any]]:
|
| 906 |
"""Get all available frameworks."""
|
|
|
|
| 925 |
# Niche-specific framework preferences
|
| 926 |
niche_preferences = {
|
| 927 |
"home_insurance": ["testimonial", "problem_solution", "authority", "before_after", "lifestyle"],
|
| 928 |
+
"glp1": ["testimonial", "lifestyle", "authority", "problem_solution", "before_after"],
|
| 929 |
"auto_insurance": ["testimonial", "problem_solution", "authority", "before_after", "comparison"],
|
| 930 |
}
|
| 931 |
|
| 932 |
# Get preferred frameworks or use all
|
| 933 |
preferred_keys = niche_preferences.get(niche_lower, list(FRAMEWORKS.keys()))
|
| 934 |
|
| 935 |
+
# Shuffle so we don't always pick the first (e.g. GLP-1 was always getting before_after when count=1)
|
| 936 |
+
shuffled_preferred = preferred_keys.copy()
|
| 937 |
+
random.shuffle(shuffled_preferred)
|
| 938 |
+
remaining = [k for k in FRAMEWORKS.keys() if k not in preferred_keys]
|
| 939 |
+
random.shuffle(remaining)
|
| 940 |
+
all_keys = shuffled_preferred + remaining
|
| 941 |
|
| 942 |
+
# Take first count from the shuffled list (random variety per niche)
|
| 943 |
selected = all_keys[:count]
|
|
|
|
| 944 |
|
| 945 |
return [{"key": k, **FRAMEWORKS[k]} for k in selected]
|
| 946 |
|
| 947 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 948 |
# ---------------------------------------------------------------------------
|
| 949 |
# Container-type framework helpers (visual format / "container" = framework with container_type=True)
|
| 950 |
# ---------------------------------------------------------------------------
|
data/glp1.py
CHANGED
|
@@ -985,6 +985,7 @@ NICHE-SPECIFIC REQUIREMENTS (GLP-1 / WEIGHT LOSS):
|
|
| 985 |
},
|
| 986 |
"image_guidance": """
|
| 987 |
NICHE REQUIREMENTS (GLP-1):
|
|
|
|
| 988 |
- Use VARIETY in visual types
|
| 989 |
- Visual options include: quiz/interactive interfaces, doctor/medical settings, person on scale, mirror reflections, lifestyle/confidence scenes, testimonial portraits, celebrity references, measurement moments, or before/after (only when the strategy specifically requires it)
|
| 990 |
- Show REAL people in various moments (not just transformation)
|
|
|
|
| 985 |
},
|
| 986 |
"image_guidance": """
|
| 987 |
NICHE REQUIREMENTS (GLP-1):
|
| 988 |
+
- CRITICAL: Every image MUST include at least ONE of: (1) A GLP-1 medication bottle or pen in the scene (e.g. Ozempic, Wegovy, Mounjaro, Zepbound - injectable pen or box), OR (2) The text "GLP-1" or a medication name (e.g. Ozempic, Wegovy, Mounjaro) visible in the image (on a label, screen, document, or surface). Do not generate a GLP-1 ad image without product or name visibility.
|
| 989 |
- Use VARIETY in visual types
|
| 990 |
- Visual options include: quiz/interactive interfaces, doctor/medical settings, person on scale, mirror reflections, lifestyle/confidence scenes, testimonial portraits, celebrity references, measurement moments, or before/after (only when the strategy specifically requires it)
|
| 991 |
- Show REAL people in various moments (not just transformation)
|
frontend/app/generate/page.tsx
CHANGED
|
@@ -208,7 +208,7 @@ export default function GeneratePage() {
|
|
| 208 |
}
|
| 209 |
};
|
| 210 |
|
| 211 |
-
const handleStandardGenerate = async (data: { niche: Niche; num_images: number; image_model?: string | null; target_audience?: string | null; offer?: string | null }) => {
|
| 212 |
reset();
|
| 213 |
setIsGenerating(true);
|
| 214 |
setGenerationStartTime(Date.now());
|
|
@@ -217,6 +217,8 @@ export default function GeneratePage() {
|
|
| 217 |
...data,
|
| 218 |
target_audience: data.target_audience || undefined,
|
| 219 |
offer: data.offer || undefined,
|
|
|
|
|
|
|
| 220 |
};
|
| 221 |
|
| 222 |
// If num_images > 1, generate batch of ads
|
|
|
|
| 208 |
}
|
| 209 |
};
|
| 210 |
|
| 211 |
+
const handleStandardGenerate = async (data: { niche: Niche; num_images: number; image_model?: string | null; target_audience?: string | null; offer?: string | null; use_trending?: boolean; trending_context?: string | null }) => {
|
| 212 |
reset();
|
| 213 |
setIsGenerating(true);
|
| 214 |
setGenerationStartTime(Date.now());
|
|
|
|
| 217 |
...data,
|
| 218 |
target_audience: data.target_audience || undefined,
|
| 219 |
offer: data.offer || undefined,
|
| 220 |
+
use_trending: data.use_trending ?? false,
|
| 221 |
+
trending_context: data.trending_context || undefined,
|
| 222 |
};
|
| 223 |
|
| 224 |
// If num_images > 1, generate batch of ads
|
frontend/components/generation/GenerationForm.tsx
CHANGED
|
@@ -166,32 +166,65 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({
|
|
| 166 |
)}
|
| 167 |
</div>
|
| 168 |
|
| 169 |
-
{/* Trending Topics
|
| 170 |
<div className="border-t border-gray-200 pt-4">
|
| 171 |
<div className="flex items-center justify-between mb-3">
|
| 172 |
-
<div
|
| 173 |
<label className="block text-sm font-semibold text-gray-700">
|
| 174 |
Use Trending Topics 🔥
|
| 175 |
</label>
|
| 176 |
<p className="text-xs text-gray-500 mt-1">
|
| 177 |
-
|
| 178 |
</p>
|
| 179 |
</div>
|
| 180 |
-
<
|
| 181 |
-
<
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
disabled
|
| 189 |
-
{...register("use_trending")}
|
| 190 |
-
/>
|
| 191 |
-
<div className="w-11 h-6 bg-gray-200 rounded-full"></div>
|
| 192 |
-
</label>
|
| 193 |
-
</div>
|
| 194 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
</div>
|
| 196 |
|
| 197 |
|
|
|
|
| 166 |
)}
|
| 167 |
</div>
|
| 168 |
|
| 169 |
+
{/* Trending Topics – AI occasions + niche news; used in ad copy generation */}
|
| 170 |
<div className="border-t border-gray-200 pt-4">
|
| 171 |
<div className="flex items-center justify-between mb-3">
|
| 172 |
+
<div>
|
| 173 |
<label className="block text-sm font-semibold text-gray-700">
|
| 174 |
Use Trending Topics 🔥
|
| 175 |
</label>
|
| 176 |
<p className="text-xs text-gray-500 mt-1">
|
| 177 |
+
Tie your ad to current occasions and niche news for timeliness
|
| 178 |
</p>
|
| 179 |
</div>
|
| 180 |
+
<label className="relative inline-flex items-center cursor-pointer">
|
| 181 |
+
<input
|
| 182 |
+
type="checkbox"
|
| 183 |
+
className="sr-only peer"
|
| 184 |
+
{...register("use_trending")}
|
| 185 |
+
/>
|
| 186 |
+
<div className="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:bg-blue-500 peer-focus:ring-2 peer-focus:ring-blue-300 transition-colors"></div>
|
| 187 |
+
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
</div>
|
| 189 |
+
{useTrending && (
|
| 190 |
+
<div className="mt-3 space-y-3 rounded-xl border border-gray-200 bg-gray-50/80 p-3">
|
| 191 |
+
<Button
|
| 192 |
+
type="button"
|
| 193 |
+
variant="secondary"
|
| 194 |
+
size="sm"
|
| 195 |
+
onClick={handleFetchTrends}
|
| 196 |
+
disabled={isFetchingTrends}
|
| 197 |
+
className="gap-2"
|
| 198 |
+
>
|
| 199 |
+
{isFetchingTrends ? <Loader2 className="h-4 w-4 animate-spin" /> : <TrendingUp className="h-4 w-4" />}
|
| 200 |
+
{isFetchingTrends ? "Fetching…" : "Fetch current trends"}
|
| 201 |
+
</Button>
|
| 202 |
+
{trendsError && <p className="text-sm text-red-600">{trendsError}</p>}
|
| 203 |
+
{trends.length > 0 && (
|
| 204 |
+
<div className="space-y-2">
|
| 205 |
+
<p className="text-xs font-medium text-gray-600">Pick one (optional – otherwise we use the top trend):</p>
|
| 206 |
+
<div className="max-h-40 overflow-y-auto space-y-1.5">
|
| 207 |
+
{trends.map((trend) => (
|
| 208 |
+
<button
|
| 209 |
+
key={trend.title}
|
| 210 |
+
type="button"
|
| 211 |
+
onClick={() => handleSelectTrend(trend)}
|
| 212 |
+
className={`w-full text-left px-3 py-2 rounded-lg border text-sm transition-colors ${
|
| 213 |
+
selectedTrend?.title === trend.title
|
| 214 |
+
? "border-blue-500 bg-blue-50 text-blue-800"
|
| 215 |
+
: "border-gray-200 bg-white hover:bg-gray-100"
|
| 216 |
+
}`}
|
| 217 |
+
>
|
| 218 |
+
<span className="font-medium">{trend.title}</span>
|
| 219 |
+
{selectedTrend?.title === trend.title && <Check className="inline h-4 w-4 ml-1 text-blue-600" />}
|
| 220 |
+
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{trend.description}</p>
|
| 221 |
+
</button>
|
| 222 |
+
))}
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
)}
|
| 226 |
+
</div>
|
| 227 |
+
)}
|
| 228 |
</div>
|
| 229 |
|
| 230 |
|
main.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
scripts/test_trends.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Quick test for trending topics: AI occasions + niche trends. Run from repo root: python scripts/test_trends.py"""
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def main():
|
| 12 |
+
from services.current_occasions import get_current_occasions
|
| 13 |
+
|
| 14 |
+
print("=== 1. Current occasions (AI) ===\n")
|
| 15 |
+
occasions = await get_current_occasions()
|
| 16 |
+
if not occasions:
|
| 17 |
+
print("No occasions returned (AI may have failed or returned empty).")
|
| 18 |
+
else:
|
| 19 |
+
for i, o in enumerate(occasions, 1):
|
| 20 |
+
print(f" {i}. {o['title']}")
|
| 21 |
+
print(f" {o['summary']}")
|
| 22 |
+
print()
|
| 23 |
+
print(f"Total: {len(occasions)} occasions\n")
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
from services.trend_monitor import trend_monitor
|
| 27 |
+
print("=== 2. Full trends for niche 'home_insurance' (occasions + news) ===\n")
|
| 28 |
+
data = await trend_monitor.get_relevant_trends_for_niche("home_insurance")
|
| 29 |
+
trends = data.get("relevant_trends") or []
|
| 30 |
+
for i, t in enumerate(trends[:8], 1):
|
| 31 |
+
print(f" {i}. [{t.get('category', '?')}] {t.get('title', '')}")
|
| 32 |
+
print(f" {(t.get('summary') or '')[:120]}...")
|
| 33 |
+
print()
|
| 34 |
+
print(f"Total trends: {len(trends)}")
|
| 35 |
+
except ImportError as e:
|
| 36 |
+
print("=== 2. Skipping full trends (missing dependency):", e)
|
| 37 |
+
print(" Install with: pip install gnews")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
if __name__ == "__main__":
|
| 41 |
+
asyncio.run(main())
|
services/current_occasions.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Current Occasions – AI-driven trending context for ad generation.
|
| 3 |
+
Uses the LLM to infer relevant occasions from the current date (holidays,
|
| 4 |
+
cultural moments, seasonal themes). No fallbacks; results are cached per day.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from datetime import date
|
| 8 |
+
from typing import List, Dict, Any
|
| 9 |
+
|
| 10 |
+
# Daily cache: date.isoformat() -> list of occasion dicts (title, summary, category, relevance_window)
|
| 11 |
+
_OCCASIONS_CACHE: Dict[str, List[Dict]] = {}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _normalize_ai_occasions(raw: Any) -> List[Dict]:
|
| 15 |
+
"""Parse and normalize LLM output to our occasion shape."""
|
| 16 |
+
if not raw:
|
| 17 |
+
return []
|
| 18 |
+
items = raw.get("occasions") if isinstance(raw, dict) else raw
|
| 19 |
+
if not isinstance(items, list):
|
| 20 |
+
return []
|
| 21 |
+
normalized: List[Dict] = []
|
| 22 |
+
for o in items:
|
| 23 |
+
if not isinstance(o, dict):
|
| 24 |
+
continue
|
| 25 |
+
title = o.get("title") or o.get("name")
|
| 26 |
+
summary = o.get("summary") or o.get("description")
|
| 27 |
+
if not title or not summary:
|
| 28 |
+
continue
|
| 29 |
+
normalized.append({
|
| 30 |
+
"title": str(title).strip(),
|
| 31 |
+
"summary": str(summary).strip(),
|
| 32 |
+
"category": str(o.get("category", "Occasion")).strip() or "Occasion",
|
| 33 |
+
"relevance_window": str(o.get("relevance_window", "week")).strip() or "week",
|
| 34 |
+
})
|
| 35 |
+
return normalized
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def get_current_occasions(today: date | None = None) -> List[Dict]:
|
| 39 |
+
"""
|
| 40 |
+
Return current occasions for ad trending context.
|
| 41 |
+
Results are cached per day.
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
List of dicts: title, summary, category, relevance_window (empty if AI fails).
|
| 45 |
+
"""
|
| 46 |
+
if today is None:
|
| 47 |
+
today = date.today()
|
| 48 |
+
cache_key = today.isoformat()
|
| 49 |
+
|
| 50 |
+
if cache_key in _OCCASIONS_CACHE:
|
| 51 |
+
return _OCCASIONS_CACHE[cache_key]
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
from services.llm import llm_service
|
| 55 |
+
except Exception:
|
| 56 |
+
_OCCASIONS_CACHE[cache_key] = []
|
| 57 |
+
return []
|
| 58 |
+
|
| 59 |
+
system_prompt = """You are an expert in global holidays, cultural moments, and seasonal themes used for advertising and marketing.
|
| 60 |
+
Given a date, list 4–6 occasions that are relevant RIGHT NOW or in the next few days for that date. Include:
|
| 61 |
+
- Official holidays (US, India, global where relevant)
|
| 62 |
+
- Cultural/seasonal moments (e.g. Valentine's Week with daily themes like Rose Day, Teddy Day; Black Friday; back-to-school)
|
| 63 |
+
- Awareness days/weeks/months (e.g. Black History Month, Earth Day)
|
| 64 |
+
- Shopping or behavior moments (Singles' Day, Prime Day, etc.)
|
| 65 |
+
Be specific to the exact date when it matters (e.g. "Teddy Day" on Feb 10, "Valentine's Day" on Feb 14).
|
| 66 |
+
Respond with valid JSON only, in this exact shape (no extra fields):
|
| 67 |
+
{"occasions": [{"title": "...", "summary": "One sentence on why it matters for ads.", "category": "Occasion", "relevance_window": "day"|"week"|"month"}]}
|
| 68 |
+
Use relevance_window: "day" for single-day events, "week" for a week-long moment, "month" for month-long themes."""
|
| 69 |
+
|
| 70 |
+
user_prompt = f"""Today's date is {today.isoformat()} ({today.strftime('%A, %B %d, %Y')}).
|
| 71 |
+
List 4–6 current or upcoming occasions that are relevant for advertising and marketing on or around this date. Consider global and regional relevance. Output JSON only."""
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
response = await llm_service.generate_json(
|
| 75 |
+
prompt=user_prompt,
|
| 76 |
+
system_prompt=system_prompt,
|
| 77 |
+
temperature=0.3,
|
| 78 |
+
)
|
| 79 |
+
occasions = _normalize_ai_occasions(response)
|
| 80 |
+
except Exception as e:
|
| 81 |
+
print(f"⚠️ AI occasions failed: {e}")
|
| 82 |
+
occasions = []
|
| 83 |
+
|
| 84 |
+
_OCCASIONS_CACHE[cache_key] = occasions
|
| 85 |
+
return occasions
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def clear_occasions_cache() -> None:
|
| 89 |
+
"""Clear the occasions cache (e.g. for tests)."""
|
| 90 |
+
global _OCCASIONS_CACHE
|
| 91 |
+
_OCCASIONS_CACHE = {}
|
services/generator.py
CHANGED
|
@@ -1,79 +1,75 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
Combines LLM + Image generation with maximum randomization for variety
|
| 4 |
-
Uses professional prompting techniques for PsyAdGenesis
|
| 5 |
-
Saves ad creatives to Neon database with image URLs
|
| 6 |
-
"""
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
| 11 |
|
| 12 |
-
|
|
|
|
| 13 |
import os
|
| 14 |
-
import sys
|
| 15 |
import random
|
| 16 |
import uuid
|
| 17 |
-
import json
|
| 18 |
-
import asyncio
|
| 19 |
from datetime import datetime
|
| 20 |
-
from typing import
|
| 21 |
|
| 22 |
-
# Add parent directory to path for imports
|
| 23 |
-
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 24 |
-
|
| 25 |
-
# Local application imports
|
| 26 |
from config import settings
|
| 27 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
from services.image import image_service
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
# Optional service imports
|
| 31 |
try:
|
| 32 |
from services.database import db_service
|
| 33 |
except ImportError:
|
| 34 |
db_service = None
|
| 35 |
-
print("Note: Database service not available (asyncpg not installed). Ads will be generated but not saved to database.")
|
| 36 |
|
| 37 |
try:
|
| 38 |
from services.r2_storage import get_r2_storage
|
| 39 |
r2_storage_available = True
|
| 40 |
except ImportError:
|
| 41 |
r2_storage_available = False
|
| 42 |
-
print("Note: R2 storage not available. Images will only be saved locally.")
|
| 43 |
|
| 44 |
try:
|
| 45 |
from services.third_flow import third_flow_service
|
| 46 |
third_flow_available = True
|
| 47 |
except ImportError:
|
| 48 |
third_flow_available = False
|
| 49 |
-
print("Note: Extensive service not available.")
|
| 50 |
|
| 51 |
try:
|
| 52 |
from services.trend_monitor import trend_monitor
|
| 53 |
trend_monitor_available = True
|
| 54 |
except ImportError:
|
| 55 |
trend_monitor_available = False
|
| 56 |
-
print("Note: Trend monitor service not available.")
|
| 57 |
-
|
| 58 |
-
# Data module imports
|
| 59 |
-
from data import home_insurance, glp1, auto_insurance
|
| 60 |
-
from services.matrix import matrix_service
|
| 61 |
-
from data.frameworks import (
|
| 62 |
-
get_frameworks_for_niche, get_framework_hook_examples, get_all_frameworks,
|
| 63 |
-
get_framework, get_framework_visual_guidance,
|
| 64 |
-
)
|
| 65 |
-
from data.hooks import get_random_hook_style, get_power_words, get_random_cta as get_hook_cta
|
| 66 |
-
from data.triggers import get_random_trigger, get_trigger_combination, get_triggers_for_niche
|
| 67 |
-
from data.visuals import (
|
| 68 |
-
get_random_visual_style, get_random_camera_angle, get_random_lighting,
|
| 69 |
-
get_random_composition, get_random_mood, get_color_palette, get_niche_visual_guidance
|
| 70 |
-
)
|
| 71 |
|
| 72 |
-
#
|
| 73 |
-
#
|
| 74 |
-
#
|
| 75 |
|
| 76 |
-
# Niche data loaders
|
| 77 |
NICHE_DATA = {
|
| 78 |
"home_insurance": home_insurance.get_niche_data,
|
| 79 |
"glp1": glp1.get_niche_data,
|
|
@@ -119,14 +115,20 @@ FILM_DAMAGE_EFFECTS = [
|
|
| 119 |
|
| 120 |
class AdGenerator:
|
| 121 |
"""
|
| 122 |
-
Generates
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
"""
|
| 126 |
-
|
| 127 |
-
#
|
| 128 |
-
# INITIALIZATION & UTILITY METHODS
|
| 129 |
-
# ========================================================================
|
| 130 |
|
| 131 |
def __init__(self):
|
| 132 |
"""Initialize the generator."""
|
|
@@ -176,6 +178,8 @@ class AdGenerator:
|
|
| 176 |
# DATA RETRIEVAL & CACHING METHODS
|
| 177 |
# ========================================================================
|
| 178 |
|
|
|
|
|
|
|
| 179 |
def _get_niche_data(self, niche: str) -> Dict[str, Any]:
|
| 180 |
"""Load data for a specific niche (cached for performance)."""
|
| 181 |
if niche not in NICHE_DATA:
|
|
@@ -428,45 +432,13 @@ class AdGenerator:
|
|
| 428 |
# NICHE & CONTENT CONFIGURATION METHODS
|
| 429 |
# ========================================================================
|
| 430 |
|
|
|
|
|
|
|
| 431 |
def _get_niche_specific_guidance(self, niche: str) -> str:
|
| 432 |
"""Get niche-specific guidance for the prompt."""
|
| 433 |
niche_data = self._get_niche_data(niche)
|
| 434 |
return niche_data.get("niche_guidance", "")
|
| 435 |
|
| 436 |
-
async def _get_framework_hook_examples_async(self, framework_key: str, niche: Optional[str] = None) -> List[str]:
|
| 437 |
-
"""
|
| 438 |
-
Get hook examples for a framework. Uses AI generation when enabled, else static from frameworks.py.
|
| 439 |
-
Falls back to static examples on AI failure or when use_ai_generated_hooks is False.
|
| 440 |
-
"""
|
| 441 |
-
if not getattr(settings, "use_ai_generated_hooks", False):
|
| 442 |
-
return get_framework_hook_examples(framework_key, niche)
|
| 443 |
-
framework = get_framework(framework_key)
|
| 444 |
-
if not framework:
|
| 445 |
-
return get_framework_hook_examples(framework_key, niche)
|
| 446 |
-
niche_label = (niche or "").replace("_", " ").title() or "general advertising"
|
| 447 |
-
prompt = f"""Generate 6 to 8 short ad hook examples (headline-style phrases) for this ad framework.
|
| 448 |
-
|
| 449 |
-
Framework: {framework.get('name', framework_key)}
|
| 450 |
-
Description: {framework.get('description', '')}
|
| 451 |
-
Tone: {framework.get('tone', '')}
|
| 452 |
-
Headline style: {framework.get('headline_style', '')}
|
| 453 |
-
Niche/context: {niche_label}
|
| 454 |
-
|
| 455 |
-
Rules:
|
| 456 |
-
- Each hook must be one short phrase or sentence (under 12 words).
|
| 457 |
-
- Match the framework's tone and style.
|
| 458 |
-
- Make them punchy and scroll-stopping; no generic filler.
|
| 459 |
-
- Return ONLY a JSON object with one key "hooks" containing an array of strings. No other text."""
|
| 460 |
-
|
| 461 |
-
try:
|
| 462 |
-
result = await llm_service.generate_json(prompt=prompt, temperature=0.8)
|
| 463 |
-
hooks = result.get("hooks") if isinstance(result, dict) else None
|
| 464 |
-
if isinstance(hooks, list) and len(hooks) > 0:
|
| 465 |
-
return [str(h).strip() for h in hooks if h]
|
| 466 |
-
except Exception:
|
| 467 |
-
pass
|
| 468 |
-
return get_framework_hook_examples(framework_key, niche)
|
| 469 |
-
|
| 470 |
async def _generate_ctas_async(
|
| 471 |
self, niche: str, framework_name: Optional[str] = None
|
| 472 |
) -> List[str]:
|
|
@@ -546,10 +518,8 @@ Rules:
|
|
| 546 |
}
|
| 547 |
return {}
|
| 548 |
|
| 549 |
-
#
|
| 550 |
-
|
| 551 |
-
# ========================================================================
|
| 552 |
-
|
| 553 |
def _build_copy_prompt(
|
| 554 |
self,
|
| 555 |
niche: str,
|
|
@@ -559,7 +529,6 @@ Rules:
|
|
| 559 |
creative_direction: str,
|
| 560 |
framework: str,
|
| 561 |
framework_data: Dict[str, Any],
|
| 562 |
-
framework_hooks: List[str],
|
| 563 |
cta: str,
|
| 564 |
trigger_data: Dict[str, Any] = None,
|
| 565 |
trigger_combination: Dict[str, Any] = None,
|
|
@@ -584,169 +553,13 @@ Rules:
|
|
| 584 |
niche_numbers = self._generate_niche_numbers(niche)
|
| 585 |
age_bracket = random.choice(AGE_BRACKETS)
|
| 586 |
|
| 587 |
-
#
|
| 588 |
num_type = niche_data.get("number_config", {}).get("type", "savings")
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
- Total Lost: {niche_numbers['difference']}
|
| 595 |
-
- Timeframe: {niche_numbers['days']}
|
| 596 |
-
- Sizes Dropped: {niche_numbers['sizes']}
|
| 597 |
-
- Target Age Bracket: {age_bracket['label']}
|
| 598 |
-
|
| 599 |
-
DECISION: You decide whether to include these numbers based on:
|
| 600 |
-
- The psychological angle (some angles work better with numbers, others without)
|
| 601 |
-
- The psychological strategy (some strategies benefit from specificity, others from emotional appeal)
|
| 602 |
-
- The overall message flow
|
| 603 |
-
|
| 604 |
-
If including numbers: Use them naturally and make them oddly specific (e.g., "47 lbs" not "50 lbs") for believability.
|
| 605 |
-
If NOT including numbers: Focus on emotional transformation, lifestyle benefits, and outcomes without specific metrics."""
|
| 606 |
-
else:
|
| 607 |
-
niche_label = niche.replace("_", " ").upper()
|
| 608 |
-
numbers_section = f"""=== NUMBERS GUIDANCE ({niche_label}) ===
|
| 609 |
-
You may include specific prices/numbers if they enhance the ad's believability and fit the format:
|
| 610 |
-
- Price Guidance: {price_guidance}
|
| 611 |
-
- Before Price: {niche_numbers['before']}
|
| 612 |
-
- After Price: {niche_numbers['after']}
|
| 613 |
-
- Total Saved: {niche_numbers['difference']}/year
|
| 614 |
-
- Target Age Bracket: {age_bracket['label']}
|
| 615 |
-
|
| 616 |
-
DECISION: You decide whether to include prices/numbers based on:
|
| 617 |
-
- The psychological angle (some angles benefit from prices, others may not)
|
| 618 |
-
- The psychological strategy (some strategies need specificity, others work better emotionally)
|
| 619 |
-
- The overall message flow and what feels most authentic
|
| 620 |
-
|
| 621 |
-
If including prices: Use oddly specific amounts (e.g., "$97.33/month" not "$100/month") for maximum believability.
|
| 622 |
-
If NOT including prices: Focus on emotional benefits, problem-solution framing, curiosity gaps, and trust without specific dollar amounts."""
|
| 623 |
-
|
| 624 |
-
# Headline formulas: niche-specific so copy matches the niche (no home-insurance formulas for auto, etc.)
|
| 625 |
-
if num_type == "weight_loss":
|
| 626 |
-
headline_formulas = """=== PROVEN WINNING HEADLINE FORMULAS (WEIGHT LOSS) ===
|
| 627 |
-
|
| 628 |
-
WITH NUMBERS (use if numbers section provided):
|
| 629 |
-
1. THE TRANSFORMATION: Specific weight loss results
|
| 630 |
-
- "Lost 47 lbs In 90 Days"
|
| 631 |
-
- "Down 4 Dress Sizes In 8 Weeks"
|
| 632 |
-
- "From 247 lbs to 168 lbs"
|
| 633 |
-
|
| 634 |
-
WITHOUT NUMBERS (use if no numbers section):
|
| 635 |
-
1. THE ACCUSATION: Direct accusation about weight struggle
|
| 636 |
-
- "Still Overweight?"
|
| 637 |
-
- "Another Failed Diet?"
|
| 638 |
-
- "Tired Of Hiding Your Body?"
|
| 639 |
-
|
| 640 |
-
2. THE CURIOSITY GAP: Open loop about weight loss secret
|
| 641 |
-
- "Thousands Are Losing Weight After THIS"
|
| 642 |
-
- "Doctors Are Prescribing THIS Instead Of Diets"
|
| 643 |
-
- "What Hollywood Has Used For Years"
|
| 644 |
-
|
| 645 |
-
3. THE BEFORE/AFTER: Dramatic transformation proof
|
| 646 |
-
- "Same Person. 90 Days Apart."
|
| 647 |
-
- "Is This Even The Same Person?"
|
| 648 |
-
- "The Transformation That Shocked Everyone"
|
| 649 |
-
|
| 650 |
-
4. THE IDENTITY CALLOUT: Target demographics
|
| 651 |
-
- "Women Over 40: This Changes Everything"
|
| 652 |
-
- "If You've Tried Every Diet And Failed..."
|
| 653 |
-
- "For People Who've Struggled For Years"
|
| 654 |
-
|
| 655 |
-
5. THE MEDICAL AUTHORITY: Doctor/FDA credibility
|
| 656 |
-
- "FDA-Approved Weight Loss"
|
| 657 |
-
- "Doctor-Prescribed. Clinically Proven."
|
| 658 |
-
- "What Doctors Prescribe Their Own Families\""""
|
| 659 |
-
elif niche == "auto_insurance":
|
| 660 |
-
headline_formulas = """=== PROVEN WINNING HEADLINE FORMULAS (AUTO INSURANCE) ===
|
| 661 |
-
|
| 662 |
-
WITH NUMBERS (use if numbers section provided):
|
| 663 |
-
1. THE SPECIFIC PRICE ANCHOR: Oddly specific = believable
|
| 664 |
-
- "Car Insurance for as low as $29/month"
|
| 665 |
-
- "Drivers Won't Have To Pay More Than $39 A Month"
|
| 666 |
-
|
| 667 |
-
2. THE BEFORE/AFTER PROOF: Savings with evidence
|
| 668 |
-
- "WAS: $1,842 → NOW: $647"
|
| 669 |
-
- "The Easiest Way To Cut Car Insurance Bills"
|
| 670 |
-
|
| 671 |
-
WITHOUT NUMBERS (use if no numbers section):
|
| 672 |
-
1. THE ACCUSATION: Direct accusation about overpaying
|
| 673 |
-
- "OVERPAYING?"
|
| 674 |
-
- "Still Overpaying For Car Insurance?"
|
| 675 |
-
- "Wasting Money On Auto Insurance?"
|
| 676 |
-
|
| 677 |
-
2. THE CURIOSITY GAP: Open loop that demands click
|
| 678 |
-
- "Drivers Are Ditching Their Auto Insurance & Doing This Instead"
|
| 679 |
-
- "Thousands of drivers are dropping insurance after THIS"
|
| 680 |
-
- "Why Are Drivers Switching?"
|
| 681 |
-
|
| 682 |
-
3. THE IDENTITY CALLOUT: Target demographics (drivers, not "seniors" or "homeowners")
|
| 683 |
-
- "Drivers Over 50: Check Your Eligibility"
|
| 684 |
-
- "Safe Drivers: Check Your Rate"
|
| 685 |
-
|
| 686 |
-
4. THE AUTHORITY TRANSFER: Government/institutional trust
|
| 687 |
-
- "State Program Cuts Insurance Costs"
|
| 688 |
-
- "Official: Safe Drivers Qualify For Reduced Rates"
|
| 689 |
-
|
| 690 |
-
5. THE EMOTIONAL BENEFIT: Focus on outcomes
|
| 691 |
-
- "Protect What Matters Most"
|
| 692 |
-
- "Finally, Peace of Mind On The Road"
|
| 693 |
-
- "Drive Confident Knowing You're Covered\""""
|
| 694 |
-
else:
|
| 695 |
-
headline_formulas = """=== PROVEN WINNING HEADLINE FORMULAS (HOME INSURANCE) ===
|
| 696 |
-
|
| 697 |
-
WITH NUMBERS (use if numbers section provided):
|
| 698 |
-
1. THE SPECIFIC PRICE ANCHOR: Oddly specific = believable
|
| 699 |
-
- "Home Insurance for as low as $97.33/month"
|
| 700 |
-
- "Seniors Won't Have To Pay More Than $49 A Month"
|
| 701 |
-
|
| 702 |
-
2. THE BEFORE/AFTER PROOF: Savings with evidence
|
| 703 |
-
- "WAS: $1,701 → NOW: $583"
|
| 704 |
-
- "The Easiest Way To Cut Home Insurance Bills"
|
| 705 |
-
|
| 706 |
-
WITHOUT NUMBERS (use if no numbers section):
|
| 707 |
-
1. THE ACCUSATION: Direct accusation about overpaying
|
| 708 |
-
- "OVERPAYING?"
|
| 709 |
-
- "Still Underinsured?"
|
| 710 |
-
- "Wasting Money On Insurance?"
|
| 711 |
-
|
| 712 |
-
2. THE CURIOSITY GAP: Open loop that demands click
|
| 713 |
-
- "Seniors Are Ditching Home Insurance & Doing This Instead"
|
| 714 |
-
- "Thousands of homeowners are dropping insurance after THIS"
|
| 715 |
-
- "Why Are Homeowners Switching?"
|
| 716 |
-
|
| 717 |
-
3. THE IDENTITY CALLOUT: Target demographics
|
| 718 |
-
- "Homeowners Over 50: Check Your Eligibility"
|
| 719 |
-
- "Senior homeowners over the age of 50..."
|
| 720 |
-
|
| 721 |
-
4. THE AUTHORITY TRANSFER: Government/institutional trust
|
| 722 |
-
- "State Farm Brings Welfare!"
|
| 723 |
-
- "Sponsored by the US Government"
|
| 724 |
-
|
| 725 |
-
5. THE EMOTIONAL BENEFIT: Focus on outcomes
|
| 726 |
-
- "Protect What Matters Most"
|
| 727 |
-
- "Finally, Peace of Mind"
|
| 728 |
-
- "Sleep Better Knowing You're Covered\""""
|
| 729 |
-
|
| 730 |
-
# Build trending topics section if available
|
| 731 |
-
trending_section = ""
|
| 732 |
-
if trending_context:
|
| 733 |
-
trending_section = f"""
|
| 734 |
-
=== TRENDING TOPICS CONTEXT (INCORPORATE THIS!) ===
|
| 735 |
-
Current Trend: {trending_context}
|
| 736 |
-
|
| 737 |
-
INSTRUCTIONS FOR USING TRENDING TOPICS:
|
| 738 |
-
- Subtly reference or tie the ad message to this trending topic
|
| 739 |
-
- Make the connection feel natural, not forced
|
| 740 |
-
- Use the trend to create urgency or relevance ("Everyone's talking about...")
|
| 741 |
-
- The trend should enhance the hook, not overshadow the core message
|
| 742 |
-
- Examples:
|
| 743 |
-
* "With [trend], now is the perfect time to..."
|
| 744 |
-
* "While everyone's focused on [trend], don't forget about..."
|
| 745 |
-
* "Just like [trend], your [product benefit]..."
|
| 746 |
-
* Reference the trend indirectly in the hook or primary text
|
| 747 |
-
|
| 748 |
-
NOTE: The trend adds timeliness and relevance. Use it strategically!
|
| 749 |
-
"""
|
| 750 |
|
| 751 |
prompt = f"""You are an elite direct-response copywriter who has reverse-engineered hundreds of 7-8 figure Facebook ad campaigns. You understand the psychology of scroll-stopping creatives that bypass ad-blindness and trigger immediate emotional response.
|
| 752 |
|
|
@@ -756,7 +569,7 @@ ADVERTISING FRAMEWORK: {framework}
|
|
| 756 |
FRAMEWORK DESCRIPTION: {framework_data.get('description', '')}
|
| 757 |
FRAMEWORK TONE: {framework_data.get('tone', '')}
|
| 758 |
FRAMEWORK VISUAL STYLE: {framework_data.get('visual_style', '')}
|
| 759 |
-
FRAMEWORK
|
| 760 |
CREATIVE DIRECTION: {creative_direction}
|
| 761 |
CALL-TO-ACTION: {cta}
|
| 762 |
{trending_section}
|
|
@@ -802,7 +615,6 @@ Incorporate these power words naturally: {', '.join(power_words) if power_words
|
|
| 802 |
|
| 803 |
=== HOOK INSPIRATION (create your own powerful variation) ===
|
| 804 |
{chr(10).join(f'- "{hook}"' for hook in hooks)}
|
| 805 |
-
FRAMEWORK HOOK EXAMPLES: {', '.join(framework_hooks[:5]) if framework_hooks else 'N/A'}
|
| 806 |
|
| 807 |
{niche_guidance}
|
| 808 |
|
|
@@ -845,7 +657,7 @@ Create a SCROLL-STOPPING Facebook ad for {niche.replace("_", " ").upper()} using
|
|
| 845 |
- {f"If document-style framework (e.g. memo, email): Include readable, properly formatted text related to {niche.replace('_', ' ').title()}." if 'document_style' in framework_data.get('tags', []) else ""}
|
| 846 |
- FOR AUTO INSURANCE: Describe ONLY one of these ad-format layouts: official notification (seal, rate buttons), social post card, rate/seniors table, before/after split (price boxes + split car if any), coverage tier panels, car brand grid, gift card CTA, or savings/urgency (yellow, CONTACT US). Do NOT describe testimonial portraits, couples, speech bubbles, quote bubbles, or people holding documents. Do NOT describe elderly or senior people. Typography, layout, prices, and buttons only. All text in the image must be readable and correctly spelled (e.g. OVERPAYING not OVERDRPAYING); no gibberish.
|
| 847 |
- FOR HOME INSURANCE: Show person with document, savings proof, home setting. People 30-60, relatable homeowners.
|
| 848 |
-
- FOR GLP-1:
|
| 849 |
|
| 850 |
=== PSYCHOLOGICAL PRINCIPLES ===
|
| 851 |
- Loss Aversion: Make them feel what they're losing/missing
|
|
@@ -878,7 +690,9 @@ Create a SCROLL-STOPPING Facebook ad for {niche.replace("_", " ").upper()} using
|
|
| 878 |
Generate the ad copy now for {niche.replace("_", " ").upper()}. Make it look like ORGANIC CONTENT that triggers IMMEDIATE emotional response."""
|
| 879 |
|
| 880 |
return prompt
|
| 881 |
-
|
|
|
|
|
|
|
| 882 |
def _build_image_prompt(
|
| 883 |
self,
|
| 884 |
niche: str,
|
|
@@ -890,12 +704,14 @@ Generate the ad copy now for {niche.replace("_", " ").upper()}. Make it look lik
|
|
| 890 |
composition: str,
|
| 891 |
visual_style_data: Optional[Dict[str, Any]] = None,
|
| 892 |
niche_visual_guidance_data: Optional[Dict[str, Any]] = None,
|
|
|
|
| 893 |
) -> str:
|
| 894 |
"""
|
| 895 |
Build professional image generation prompt.
|
| 896 |
Uses detailed specifications, style guidance, and negative prompts.
|
| 897 |
Creates AUTHENTIC, ORGANIC CONTENT aesthetic.
|
| 898 |
Text (if included) should be part of the natural scene, NOT an overlay.
|
|
|
|
| 899 |
"""
|
| 900 |
image_brief = ad_copy.get("image_brief", "")
|
| 901 |
headline = ad_copy.get("headline", "")
|
|
@@ -952,7 +768,7 @@ Generate the ad copy now for {niche.replace("_", " ").upper()}. Make it look lik
|
|
| 952 |
]
|
| 953 |
text_color = random.choice(text_colors)
|
| 954 |
|
| 955 |
-
# Niche-specific image guidance (for auto_insurance: no forced subjects/props;
|
| 956 |
if niche == "auto_insurance":
|
| 957 |
niche_data = self._get_niche_data(niche)
|
| 958 |
niche_image_guidance = (niche_data.get("image_guidance", "") + """
|
|
@@ -960,6 +776,12 @@ Generate the ad copy now for {niche.replace("_", " ").upper()}. Make it look lik
|
|
| 960 |
PEOPLE, FACES, AND CARS ARE OPTIONAL. Only include them when the VISUAL SCENE description explicitly mentions them. Most ad formats are typography, layout, and buttons only.
|
| 961 |
NO fake or made-up brand/company names (no gibberish); use generic text only or omit. NO in-car dashboard mockups or screens inside car interiors; stick to the 8 defined ad formats only."""
|
| 962 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 963 |
elif niche_visual_guidance_data and isinstance(niche_visual_guidance_data, dict):
|
| 964 |
niche_image_guidance = f"""
|
| 965 |
NICHE REQUIREMENTS ({niche.replace("_", " ").title()}):
|
|
@@ -1149,6 +971,7 @@ MOOD: {visual_mood} - {"trustworthy, clear, high-contrast" if is_auto_insurance_
|
|
| 1149 |
CAMERA: {camera_angle} - documentary/candid feel
|
| 1150 |
LIGHTING: {lighting} - natural, not studio-polished
|
| 1151 |
COMPOSITION: {composition}
|
|
|
|
| 1152 |
|
| 1153 |
{niche_image_guidance}
|
| 1154 |
|
|
@@ -1214,7 +1037,7 @@ CRITICAL REQUIREMENTS:
|
|
| 1214 |
# Refine and clean the prompt before sending (pass niche for demographic fixes)
|
| 1215 |
refined_prompt = self._refine_image_prompt(prompt, niche=niche)
|
| 1216 |
return refined_prompt
|
| 1217 |
-
|
| 1218 |
def _refine_image_prompt(self, prompt: str, niche: str = None) -> str:
|
| 1219 |
"""
|
| 1220 |
Refine and clean the image prompt for affiliate marketing creatives.
|
|
@@ -1392,7 +1215,9 @@ CRITICAL REQUIREMENTS:
|
|
| 1392 |
prompt += '.'
|
| 1393 |
|
| 1394 |
return prompt
|
| 1395 |
-
|
|
|
|
|
|
|
| 1396 |
async def generate_ad(
|
| 1397 |
self,
|
| 1398 |
niche: str,
|
|
@@ -1455,8 +1280,6 @@ CRITICAL REQUIREMENTS:
|
|
| 1455 |
creative_direction = random.choice(niche_data["creative_directions"])
|
| 1456 |
visual_mood = random.choice(niche_data["visual_moods"])
|
| 1457 |
|
| 1458 |
-
# Framework hook examples: AI-generated when enabled, else static from frameworks.py
|
| 1459 |
-
framework_hooks = await self._get_framework_hook_examples_async(framework_key, niche)
|
| 1460 |
ctas = await self._generate_ctas_async(niche, framework_data.get("name"))
|
| 1461 |
cta = random.choice(ctas) if ctas else "Learn More"
|
| 1462 |
|
|
@@ -1486,12 +1309,9 @@ CRITICAL REQUIREMENTS:
|
|
| 1486 |
if use_trending and trend_monitor_available:
|
| 1487 |
try:
|
| 1488 |
if not trending_context:
|
| 1489 |
-
# Auto-fetch current trends
|
| 1490 |
-
print("📰 Fetching current trending topics...")
|
| 1491 |
-
trends_data = await
|
| 1492 |
-
trend_monitor.get_relevant_trends_for_niche,
|
| 1493 |
-
niche.replace("_", " ").title()
|
| 1494 |
-
)
|
| 1495 |
if trends_data and trends_data.get("relevant_trends"):
|
| 1496 |
# Use top trend for context
|
| 1497 |
top_trend = trends_data["relevant_trends"][0]
|
|
@@ -1526,7 +1346,6 @@ CRITICAL REQUIREMENTS:
|
|
| 1526 |
creative_direction=creative_direction,
|
| 1527 |
framework=framework,
|
| 1528 |
framework_data=framework_data,
|
| 1529 |
-
framework_hooks=framework_hooks,
|
| 1530 |
cta=cta,
|
| 1531 |
trigger_data=trigger_data,
|
| 1532 |
trigger_combination=trigger_combination,
|
|
@@ -1552,7 +1371,7 @@ CRITICAL REQUIREMENTS:
|
|
| 1552 |
# Generate image(s) with professional prompt - PARALLELIZED
|
| 1553 |
async def generate_single_image(image_index: int):
|
| 1554 |
"""Helper function to generate a single image with all processing."""
|
| 1555 |
-
# Build image prompt with all parameters
|
| 1556 |
image_prompt = self._build_image_prompt(
|
| 1557 |
niche=niche,
|
| 1558 |
ad_copy=ad_copy,
|
|
@@ -1563,6 +1382,7 @@ CRITICAL REQUIREMENTS:
|
|
| 1563 |
composition=composition,
|
| 1564 |
visual_style_data=visual_style_data,
|
| 1565 |
niche_visual_guidance_data=niche_visual_guidance_data,
|
|
|
|
| 1566 |
)
|
| 1567 |
|
| 1568 |
# Store the refined prompt for database saving
|
|
@@ -1732,7 +1552,9 @@ CRITICAL REQUIREMENTS:
|
|
| 1732 |
}
|
| 1733 |
|
| 1734 |
return result
|
| 1735 |
-
|
|
|
|
|
|
|
| 1736 |
async def generate_ad_with_matrix(
|
| 1737 |
self,
|
| 1738 |
niche: str,
|
|
@@ -2075,6 +1897,8 @@ CONCEPT: {concept.get('name', 'Custom Concept')}
|
|
| 2075 |
},
|
| 2076 |
}
|
| 2077 |
|
|
|
|
|
|
|
| 2078 |
async def generate_ad_extensive(
|
| 2079 |
self,
|
| 2080 |
niche: str,
|
|
@@ -2088,6 +1912,7 @@ CONCEPT: {concept.get('name', 'Custom Concept')}
|
|
| 2088 |
"""
|
| 2089 |
Generate ad using extensive: researcher → creative director → designer → copywriter.
|
| 2090 |
Works for any niche: home_insurance, glp1, auto_insurance, or custom (e.g. from 'others').
|
|
|
|
| 2091 |
|
| 2092 |
Args:
|
| 2093 |
niche: Target niche (home_insurance, glp1, auto_insurance, or custom display name when 'others')
|
|
@@ -2144,7 +1969,19 @@ CONCEPT: {concept.get('name', 'Custom Concept')}
|
|
| 2144 |
)
|
| 2145 |
)
|
| 2146 |
|
| 2147 |
-
# Step
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2148 |
print(f"🎨 Step 3: Creating {num_strategies} creative strategy/strategies...")
|
| 2149 |
print(f"📋 Parameters: num_strategies={num_strategies}, num_images={num_images}")
|
| 2150 |
creative_strategies = await asyncio.to_thread(
|
|
@@ -2155,7 +1992,8 @@ CONCEPT: {concept.get('name', 'Custom Concept')}
|
|
| 2155 |
target_audience=target_audience,
|
| 2156 |
offer=offer,
|
| 2157 |
niche=niche_display,
|
| 2158 |
-
n=num_strategies
|
|
|
|
| 2159 |
)
|
| 2160 |
|
| 2161 |
if not creative_strategies:
|
|
@@ -2165,6 +2003,37 @@ CONCEPT: {concept.get('name', 'Custom Concept')}
|
|
| 2165 |
creative_strategies = creative_strategies[:num_strategies]
|
| 2166 |
print(f"📊 Using {len(creative_strategies)} strategy/strategies (requested: {num_strategies})")
|
| 2167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2168 |
# Step 4: Process strategies in parallel (designer + copywriter) - Optimized: use asyncio.to_thread instead of ThreadPoolExecutor
|
| 2169 |
print(f"⚡ Step 4: Processing {len(creative_strategies)} strategies in parallel...")
|
| 2170 |
|
|
@@ -2173,9 +2042,10 @@ CONCEPT: {concept.get('name', 'Custom Concept')}
|
|
| 2173 |
asyncio.to_thread(
|
| 2174 |
third_flow_service.process_strategy,
|
| 2175 |
strategy,
|
| 2176 |
-
niche=niche_display
|
|
|
|
| 2177 |
)
|
| 2178 |
-
for strategy in creative_strategies
|
| 2179 |
]
|
| 2180 |
strategy_results = await asyncio.gather(*strategy_tasks)
|
| 2181 |
|
|
@@ -2394,6 +2264,8 @@ CONCEPT: {concept.get('name', 'Custom Concept')}
|
|
| 2394 |
# CUSTOM ANGLE/CONCEPT REFINEMENT
|
| 2395 |
# ========================================================================
|
| 2396 |
|
|
|
|
|
|
|
| 2397 |
async def refine_custom_angle_or_concept(
|
| 2398 |
self,
|
| 2399 |
text: str,
|
|
@@ -2528,10 +2400,8 @@ Return JSON:
|
|
| 2528 |
result["original_text"] = text
|
| 2529 |
return result
|
| 2530 |
|
| 2531 |
-
#
|
| 2532 |
-
|
| 2533 |
-
# ========================================================================
|
| 2534 |
-
|
| 2535 |
def _build_matrix_ad_prompt(
|
| 2536 |
self,
|
| 2537 |
niche: str,
|
|
@@ -2745,7 +2615,9 @@ If this image includes people or faces, they MUST look like real, original peopl
|
|
| 2745 |
# Refine and clean the prompt before sending (pass niche for demographic fixes)
|
| 2746 |
refined_prompt = self._refine_image_prompt(prompt, niche=niche)
|
| 2747 |
return refined_prompt
|
| 2748 |
-
|
|
|
|
|
|
|
| 2749 |
async def generate_batch(
|
| 2750 |
self,
|
| 2751 |
niche: str,
|
|
|
|
| 1 |
"""
|
| 2 |
+
Ad Generator Service
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
+
Generates high-converting ad creatives using psychological triggers, LLM copy,
|
| 5 |
+
and image generation. Uses maximum randomization for variety and saves to the
|
| 6 |
+
Neon database with optional R2 image storage.
|
| 7 |
+
"""
|
| 8 |
|
| 9 |
+
import asyncio
|
| 10 |
+
import json
|
| 11 |
import os
|
|
|
|
| 12 |
import random
|
| 13 |
import uuid
|
|
|
|
|
|
|
| 14 |
from datetime import datetime
|
| 15 |
+
from typing import Any, Dict, List, Optional
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
from config import settings
|
| 18 |
+
from data import auto_insurance, glp1, home_insurance
|
| 19 |
+
from data.frameworks import (
|
| 20 |
+
get_all_frameworks,
|
| 21 |
+
get_framework,
|
| 22 |
+
get_framework_visual_guidance,
|
| 23 |
+
get_frameworks_for_niche,
|
| 24 |
+
)
|
| 25 |
+
from data.hooks import get_power_words, get_random_cta as get_hook_cta, get_random_hook_style
|
| 26 |
+
from data.triggers import get_random_trigger, get_trigger_combination, get_triggers_for_niche
|
| 27 |
+
from data.visuals import (
|
| 28 |
+
get_color_palette,
|
| 29 |
+
get_niche_visual_guidance,
|
| 30 |
+
get_random_camera_angle,
|
| 31 |
+
get_random_composition,
|
| 32 |
+
get_random_lighting,
|
| 33 |
+
get_random_mood,
|
| 34 |
+
get_random_visual_style,
|
| 35 |
+
)
|
| 36 |
+
from services.generator_prompts import (
|
| 37 |
+
get_headline_formulas,
|
| 38 |
+
get_numbers_section,
|
| 39 |
+
get_trending_section,
|
| 40 |
+
get_trending_image_guidance,
|
| 41 |
+
)
|
| 42 |
from services.image import image_service
|
| 43 |
+
from services.llm import llm_service
|
| 44 |
+
from services.matrix import matrix_service
|
| 45 |
|
|
|
|
| 46 |
try:
|
| 47 |
from services.database import db_service
|
| 48 |
except ImportError:
|
| 49 |
db_service = None
|
|
|
|
| 50 |
|
| 51 |
try:
|
| 52 |
from services.r2_storage import get_r2_storage
|
| 53 |
r2_storage_available = True
|
| 54 |
except ImportError:
|
| 55 |
r2_storage_available = False
|
|
|
|
| 56 |
|
| 57 |
try:
|
| 58 |
from services.third_flow import third_flow_service
|
| 59 |
third_flow_available = True
|
| 60 |
except ImportError:
|
| 61 |
third_flow_available = False
|
|
|
|
| 62 |
|
| 63 |
try:
|
| 64 |
from services.trend_monitor import trend_monitor
|
| 65 |
trend_monitor_available = True
|
| 66 |
except ImportError:
|
| 67 |
trend_monitor_available = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
+
# -----------------------------------------------------------------------------
|
| 70 |
+
# Constants
|
| 71 |
+
# -----------------------------------------------------------------------------
|
| 72 |
|
|
|
|
| 73 |
NICHE_DATA = {
|
| 74 |
"home_insurance": home_insurance.get_niche_data,
|
| 75 |
"glp1": glp1.get_niche_data,
|
|
|
|
| 115 |
|
| 116 |
class AdGenerator:
|
| 117 |
"""
|
| 118 |
+
Generates ad creatives: copy (LLM) + images, with randomization and DB/R2 save.
|
| 119 |
+
|
| 120 |
+
Sections:
|
| 121 |
+
- Init & config: output dir, local save, niche cache
|
| 122 |
+
- Niche & strategy: get niche data, compatible strategies, hooks, visuals
|
| 123 |
+
- CTAs & numbers: generate CTAs, prices, niche numbers
|
| 124 |
+
- Copy prompt: _build_copy_prompt (angle × concept, frameworks)
|
| 125 |
+
- Image prompt: _build_image_prompt, _refine_image_prompt
|
| 126 |
+
- Public: generate_ad, generate_ad_with_matrix, generate_ad_extensive, generate_batch
|
| 127 |
+
- Matrix: _build_matrix_ad_prompt, _build_matrix_image_prompt
|
| 128 |
+
- Refine: refine_custom_angle_or_concept
|
| 129 |
"""
|
| 130 |
+
|
| 131 |
+
# --- Init & config ---
|
|
|
|
|
|
|
| 132 |
|
| 133 |
def __init__(self):
|
| 134 |
"""Initialize the generator."""
|
|
|
|
| 178 |
# DATA RETRIEVAL & CACHING METHODS
|
| 179 |
# ========================================================================
|
| 180 |
|
| 181 |
+
# --- Niche & strategy ---
|
| 182 |
+
|
| 183 |
def _get_niche_data(self, niche: str) -> Dict[str, Any]:
|
| 184 |
"""Load data for a specific niche (cached for performance)."""
|
| 185 |
if niche not in NICHE_DATA:
|
|
|
|
| 432 |
# NICHE & CONTENT CONFIGURATION METHODS
|
| 433 |
# ========================================================================
|
| 434 |
|
| 435 |
+
# --- CTAs & numbers ---
|
| 436 |
+
|
| 437 |
def _get_niche_specific_guidance(self, niche: str) -> str:
|
| 438 |
"""Get niche-specific guidance for the prompt."""
|
| 439 |
niche_data = self._get_niche_data(niche)
|
| 440 |
return niche_data.get("niche_guidance", "")
|
| 441 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
async def _generate_ctas_async(
|
| 443 |
self, niche: str, framework_name: Optional[str] = None
|
| 444 |
) -> List[str]:
|
|
|
|
| 518 |
}
|
| 519 |
return {}
|
| 520 |
|
| 521 |
+
# --- Copy prompt ---
|
| 522 |
+
|
|
|
|
|
|
|
| 523 |
def _build_copy_prompt(
|
| 524 |
self,
|
| 525 |
niche: str,
|
|
|
|
| 529 |
creative_direction: str,
|
| 530 |
framework: str,
|
| 531 |
framework_data: Dict[str, Any],
|
|
|
|
| 532 |
cta: str,
|
| 533 |
trigger_data: Dict[str, Any] = None,
|
| 534 |
trigger_combination: Dict[str, Any] = None,
|
|
|
|
| 553 |
niche_numbers = self._generate_niche_numbers(niche)
|
| 554 |
age_bracket = random.choice(AGE_BRACKETS)
|
| 555 |
|
| 556 |
+
# Numbers and headline formulas from shared prompt content
|
| 557 |
num_type = niche_data.get("number_config", {}).get("type", "savings")
|
| 558 |
+
numbers_section = get_numbers_section(
|
| 559 |
+
niche, num_type, niche_numbers, age_bracket, price_guidance
|
| 560 |
+
)
|
| 561 |
+
headline_formulas = get_headline_formulas(niche, num_type)
|
| 562 |
+
trending_section = get_trending_section(trending_context)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
|
| 564 |
prompt = f"""You are an elite direct-response copywriter who has reverse-engineered hundreds of 7-8 figure Facebook ad campaigns. You understand the psychology of scroll-stopping creatives that bypass ad-blindness and trigger immediate emotional response.
|
| 565 |
|
|
|
|
| 569 |
FRAMEWORK DESCRIPTION: {framework_data.get('description', '')}
|
| 570 |
FRAMEWORK TONE: {framework_data.get('tone', '')}
|
| 571 |
FRAMEWORK VISUAL STYLE: {framework_data.get('visual_style', '')}
|
| 572 |
+
FRAMEWORK HEADLINE STYLE: {framework_data.get('headline_style', '') or 'N/A'}
|
| 573 |
CREATIVE DIRECTION: {creative_direction}
|
| 574 |
CALL-TO-ACTION: {cta}
|
| 575 |
{trending_section}
|
|
|
|
| 615 |
|
| 616 |
=== HOOK INSPIRATION (create your own powerful variation) ===
|
| 617 |
{chr(10).join(f'- "{hook}"' for hook in hooks)}
|
|
|
|
| 618 |
|
| 619 |
{niche_guidance}
|
| 620 |
|
|
|
|
| 657 |
- {f"If document-style framework (e.g. memo, email): Include readable, properly formatted text related to {niche.replace('_', ' ').title()}." if 'document_style' in framework_data.get('tags', []) else ""}
|
| 658 |
- FOR AUTO INSURANCE: Describe ONLY one of these ad-format layouts: official notification (seal, rate buttons), social post card, rate/seniors table, before/after split (price boxes + split car if any), coverage tier panels, car brand grid, gift card CTA, or savings/urgency (yellow, CONTACT US). Do NOT describe testimonial portraits, couples, speech bubbles, quote bubbles, or people holding documents. Do NOT describe elderly or senior people. Typography, layout, prices, and buttons only. All text in the image must be readable and correctly spelled (e.g. OVERPAYING not OVERDRPAYING); no gibberish.
|
| 659 |
- FOR HOME INSURANCE: Show person with document, savings proof, home setting. People 30-60, relatable homeowners.
|
| 660 |
+
- FOR GLP-1: REQUIRED - every image brief MUST describe either (1) a GLP-1 medication bottle or pen visible in the scene (e.g. Ozempic, Wegovy, Mounjaro, Zepbound pen or box), OR (2) the text "GLP-1" or a medication name (e.g. Ozempic, Wegovy) visible on a label, screen, document, or surface. Use VARIETY in visual types: quiz interfaces, doctor/medical settings, person on scale, mirror reflections, lifestyle moments, confidence scenes, testimonial portraits, celebrity references, or before/after (only when strategy calls for it). People aged 30-50, not elderly.
|
| 661 |
|
| 662 |
=== PSYCHOLOGICAL PRINCIPLES ===
|
| 663 |
- Loss Aversion: Make them feel what they're losing/missing
|
|
|
|
| 690 |
Generate the ad copy now for {niche.replace("_", " ").upper()}. Make it look like ORGANIC CONTENT that triggers IMMEDIATE emotional response."""
|
| 691 |
|
| 692 |
return prompt
|
| 693 |
+
|
| 694 |
+
# --- Image prompt ---
|
| 695 |
+
|
| 696 |
def _build_image_prompt(
|
| 697 |
self,
|
| 698 |
niche: str,
|
|
|
|
| 704 |
composition: str,
|
| 705 |
visual_style_data: Optional[Dict[str, Any]] = None,
|
| 706 |
niche_visual_guidance_data: Optional[Dict[str, Any]] = None,
|
| 707 |
+
trending_context: Optional[str] = None,
|
| 708 |
) -> str:
|
| 709 |
"""
|
| 710 |
Build professional image generation prompt.
|
| 711 |
Uses detailed specifications, style guidance, and negative prompts.
|
| 712 |
Creates AUTHENTIC, ORGANIC CONTENT aesthetic.
|
| 713 |
Text (if included) should be part of the natural scene, NOT an overlay.
|
| 714 |
+
When trending_context is set, mood and atmosphere align with the current occasion.
|
| 715 |
"""
|
| 716 |
image_brief = ad_copy.get("image_brief", "")
|
| 717 |
headline = ad_copy.get("headline", "")
|
|
|
|
| 768 |
]
|
| 769 |
text_color = random.choice(text_colors)
|
| 770 |
|
| 771 |
+
# Niche-specific image guidance (for auto_insurance: no forced subjects/props; for GLP-1: bottle or name required)
|
| 772 |
if niche == "auto_insurance":
|
| 773 |
niche_data = self._get_niche_data(niche)
|
| 774 |
niche_image_guidance = (niche_data.get("image_guidance", "") + """
|
|
|
|
| 776 |
PEOPLE, FACES, AND CARS ARE OPTIONAL. Only include them when the VISUAL SCENE description explicitly mentions them. Most ad formats are typography, layout, and buttons only.
|
| 777 |
NO fake or made-up brand/company names (no gibberish); use generic text only or omit. NO in-car dashboard mockups or screens inside car interiors; stick to the 8 defined ad formats only."""
|
| 778 |
)
|
| 779 |
+
elif niche == "glp1":
|
| 780 |
+
niche_data = self._get_niche_data(niche)
|
| 781 |
+
niche_image_guidance = (niche_data.get("image_guidance", "") + """
|
| 782 |
+
|
| 783 |
+
CRITICAL - GLP-1 PRODUCT VISIBILITY: The image MUST show at least one of: (1) A GLP-1 medication bottle or injectable pen (e.g. Ozempic, Wegovy, Mounjaro, Zepbound) in the scene, OR (2) The text "GLP-1" or a medication name (Ozempic, Wegovy, Mounjaro, etc.) visible on a label, screen, document, or surface. Do not generate a GLP-1 ad image without the product or name visible."""
|
| 784 |
+
)
|
| 785 |
elif niche_visual_guidance_data and isinstance(niche_visual_guidance_data, dict):
|
| 786 |
niche_image_guidance = f"""
|
| 787 |
NICHE REQUIREMENTS ({niche.replace("_", " ").title()}):
|
|
|
|
| 971 |
CAMERA: {camera_angle} - documentary/candid feel
|
| 972 |
LIGHTING: {lighting} - natural, not studio-polished
|
| 973 |
COMPOSITION: {composition}
|
| 974 |
+
{get_trending_image_guidance(trending_context)}
|
| 975 |
|
| 976 |
{niche_image_guidance}
|
| 977 |
|
|
|
|
| 1037 |
# Refine and clean the prompt before sending (pass niche for demographic fixes)
|
| 1038 |
refined_prompt = self._refine_image_prompt(prompt, niche=niche)
|
| 1039 |
return refined_prompt
|
| 1040 |
+
|
| 1041 |
def _refine_image_prompt(self, prompt: str, niche: str = None) -> str:
|
| 1042 |
"""
|
| 1043 |
Refine and clean the image prompt for affiliate marketing creatives.
|
|
|
|
| 1215 |
prompt += '.'
|
| 1216 |
|
| 1217 |
return prompt
|
| 1218 |
+
|
| 1219 |
+
# --- Public: single ad (standard) ---
|
| 1220 |
+
|
| 1221 |
async def generate_ad(
|
| 1222 |
self,
|
| 1223 |
niche: str,
|
|
|
|
| 1280 |
creative_direction = random.choice(niche_data["creative_directions"])
|
| 1281 |
visual_mood = random.choice(niche_data["visual_moods"])
|
| 1282 |
|
|
|
|
|
|
|
| 1283 |
ctas = await self._generate_ctas_async(niche, framework_data.get("name"))
|
| 1284 |
cta = random.choice(ctas) if ctas else "Learn More"
|
| 1285 |
|
|
|
|
| 1309 |
if use_trending and trend_monitor_available:
|
| 1310 |
try:
|
| 1311 |
if not trending_context:
|
| 1312 |
+
# Auto-fetch current trends (occasions + news); occasions are date-based (e.g. Valentine's Week)
|
| 1313 |
+
print("📰 Fetching current trending topics (occasions + news)...")
|
| 1314 |
+
trends_data = await trend_monitor.get_relevant_trends_for_niche(niche)
|
|
|
|
|
|
|
|
|
|
| 1315 |
if trends_data and trends_data.get("relevant_trends"):
|
| 1316 |
# Use top trend for context
|
| 1317 |
top_trend = trends_data["relevant_trends"][0]
|
|
|
|
| 1346 |
creative_direction=creative_direction,
|
| 1347 |
framework=framework,
|
| 1348 |
framework_data=framework_data,
|
|
|
|
| 1349 |
cta=cta,
|
| 1350 |
trigger_data=trigger_data,
|
| 1351 |
trigger_combination=trigger_combination,
|
|
|
|
| 1371 |
# Generate image(s) with professional prompt - PARALLELIZED
|
| 1372 |
async def generate_single_image(image_index: int):
|
| 1373 |
"""Helper function to generate a single image with all processing."""
|
| 1374 |
+
# Build image prompt with all parameters (include trending context so images match the occasion)
|
| 1375 |
image_prompt = self._build_image_prompt(
|
| 1376 |
niche=niche,
|
| 1377 |
ad_copy=ad_copy,
|
|
|
|
| 1382 |
composition=composition,
|
| 1383 |
visual_style_data=visual_style_data,
|
| 1384 |
niche_visual_guidance_data=niche_visual_guidance_data,
|
| 1385 |
+
trending_context=trending_context if use_trending else None,
|
| 1386 |
)
|
| 1387 |
|
| 1388 |
# Store the refined prompt for database saving
|
|
|
|
| 1552 |
}
|
| 1553 |
|
| 1554 |
return result
|
| 1555 |
+
|
| 1556 |
+
# --- Public: matrix ad ---
|
| 1557 |
+
|
| 1558 |
async def generate_ad_with_matrix(
|
| 1559 |
self,
|
| 1560 |
niche: str,
|
|
|
|
| 1897 |
},
|
| 1898 |
}
|
| 1899 |
|
| 1900 |
+
# --- Public: extensive flow ---
|
| 1901 |
+
|
| 1902 |
async def generate_ad_extensive(
|
| 1903 |
self,
|
| 1904 |
niche: str,
|
|
|
|
| 1912 |
"""
|
| 1913 |
Generate ad using extensive: researcher → creative director → designer → copywriter.
|
| 1914 |
Works for any niche: home_insurance, glp1, auto_insurance, or custom (e.g. from 'others').
|
| 1915 |
+
Motivators are auto-generated per strategy.
|
| 1916 |
|
| 1917 |
Args:
|
| 1918 |
niche: Target niche (home_insurance, glp1, auto_insurance, or custom display name when 'others')
|
|
|
|
| 1969 |
)
|
| 1970 |
)
|
| 1971 |
|
| 1972 |
+
# Step 2b: Generate motivators from research for strategy making
|
| 1973 |
+
print("💡 Step 2b: Generating motivators from research for strategy making...")
|
| 1974 |
+
motivators_from_research = await third_flow_service.generate_motivators_for_research(
|
| 1975 |
+
researcher_output=researcher_output,
|
| 1976 |
+
niche=niche_display,
|
| 1977 |
+
target_audience=target_audience,
|
| 1978 |
+
offer=offer,
|
| 1979 |
+
count_per_item=4,
|
| 1980 |
+
)
|
| 1981 |
+
if motivators_from_research:
|
| 1982 |
+
print(f" Generated {len(motivators_from_research)} motivators for creative director")
|
| 1983 |
+
|
| 1984 |
+
# Step 3: Creative Director (with motivators for strategy making)
|
| 1985 |
print(f"🎨 Step 3: Creating {num_strategies} creative strategy/strategies...")
|
| 1986 |
print(f"📋 Parameters: num_strategies={num_strategies}, num_images={num_images}")
|
| 1987 |
creative_strategies = await asyncio.to_thread(
|
|
|
|
| 1992 |
target_audience=target_audience,
|
| 1993 |
offer=offer,
|
| 1994 |
niche=niche_display,
|
| 1995 |
+
n=num_strategies,
|
| 1996 |
+
motivators=motivators_from_research if motivators_from_research else None,
|
| 1997 |
)
|
| 1998 |
|
| 1999 |
if not creative_strategies:
|
|
|
|
| 2003 |
creative_strategies = creative_strategies[:num_strategies]
|
| 2004 |
print(f"📊 Using {len(creative_strategies)} strategy/strategies (requested: {num_strategies})")
|
| 2005 |
|
| 2006 |
+
# Step 3b: Get motivator per strategy (from strategy if assigned by director, else generate)
|
| 2007 |
+
print(f"💡 Step 3b: Resolving motivators for each strategy...")
|
| 2008 |
+
strategies_needing_motivators = [
|
| 2009 |
+
(idx, s) for idx, s in enumerate(creative_strategies)
|
| 2010 |
+
if not s.motivators or len(s.motivators) == 0
|
| 2011 |
+
]
|
| 2012 |
+
generated_motivators: dict[int, str | None] = {}
|
| 2013 |
+
if strategies_needing_motivators:
|
| 2014 |
+
gen_tasks = [
|
| 2015 |
+
third_flow_service.generate_motivators_for_strategy(
|
| 2016 |
+
strategy=s, niche=niche_display,
|
| 2017 |
+
target_audience=target_audience, offer=offer, count=6,
|
| 2018 |
+
)
|
| 2019 |
+
for _, s in strategies_needing_motivators
|
| 2020 |
+
]
|
| 2021 |
+
gen_results = await asyncio.gather(*gen_tasks)
|
| 2022 |
+
for (idx, _), motivators in zip(strategies_needing_motivators, gen_results):
|
| 2023 |
+
generated_motivators[idx] = motivators[0] if motivators else None
|
| 2024 |
+
motivators_per_strategy = []
|
| 2025 |
+
for idx, strategy in enumerate(creative_strategies):
|
| 2026 |
+
if strategy.motivators and len(strategy.motivators) > 0:
|
| 2027 |
+
m = strategy.motivators[0]
|
| 2028 |
+
motivators_per_strategy.append(m)
|
| 2029 |
+
if m:
|
| 2030 |
+
print(f" Strategy {idx + 1} (from director): \"{m[:60]}...\"" if len(m) > 60 else f" Strategy {idx + 1} (from director): \"{m}\"")
|
| 2031 |
+
else:
|
| 2032 |
+
sel = generated_motivators.get(idx)
|
| 2033 |
+
motivators_per_strategy.append(sel)
|
| 2034 |
+
if sel:
|
| 2035 |
+
print(f" Strategy {idx + 1} (generated): \"{sel[:60]}...\"" if len(sel) > 60 else f" Strategy {idx + 1} (generated): \"{sel}\"")
|
| 2036 |
+
|
| 2037 |
# Step 4: Process strategies in parallel (designer + copywriter) - Optimized: use asyncio.to_thread instead of ThreadPoolExecutor
|
| 2038 |
print(f"⚡ Step 4: Processing {len(creative_strategies)} strategies in parallel...")
|
| 2039 |
|
|
|
|
| 2042 |
asyncio.to_thread(
|
| 2043 |
third_flow_service.process_strategy,
|
| 2044 |
strategy,
|
| 2045 |
+
niche=niche_display,
|
| 2046 |
+
selected_motivator=motivators_per_strategy[idx] if idx < len(motivators_per_strategy) else None,
|
| 2047 |
)
|
| 2048 |
+
for idx, strategy in enumerate(creative_strategies)
|
| 2049 |
]
|
| 2050 |
strategy_results = await asyncio.gather(*strategy_tasks)
|
| 2051 |
|
|
|
|
| 2264 |
# CUSTOM ANGLE/CONCEPT REFINEMENT
|
| 2265 |
# ========================================================================
|
| 2266 |
|
| 2267 |
+
# --- Refine custom angle/concept ---
|
| 2268 |
+
|
| 2269 |
async def refine_custom_angle_or_concept(
|
| 2270 |
self,
|
| 2271 |
text: str,
|
|
|
|
| 2400 |
result["original_text"] = text
|
| 2401 |
return result
|
| 2402 |
|
| 2403 |
+
# --- Matrix prompt builders ---
|
| 2404 |
+
|
|
|
|
|
|
|
| 2405 |
def _build_matrix_ad_prompt(
|
| 2406 |
self,
|
| 2407 |
niche: str,
|
|
|
|
| 2615 |
# Refine and clean the prompt before sending (pass niche for demographic fixes)
|
| 2616 |
refined_prompt = self._refine_image_prompt(prompt, niche=niche)
|
| 2617 |
return refined_prompt
|
| 2618 |
+
|
| 2619 |
+
# --- Public: batch ---
|
| 2620 |
+
|
| 2621 |
async def generate_batch(
|
| 2622 |
self,
|
| 2623 |
niche: str,
|
services/generator_prompts.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prompt content and section builders for the ad generator.
|
| 3 |
+
Keeps long copy-prompt strings out of the main generator class.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Dict, Any, Optional
|
| 7 |
+
|
| 8 |
+
# -----------------------------------------------------------------------------
|
| 9 |
+
# Headline formulas by niche / number type
|
| 10 |
+
# -----------------------------------------------------------------------------
|
| 11 |
+
|
| 12 |
+
HEADLINE_FORMULAS_WEIGHT_LOSS = """=== PROVEN WINNING HEADLINE FORMULAS (WEIGHT LOSS) ===
|
| 13 |
+
|
| 14 |
+
WITH NUMBERS (use if numbers section provided):
|
| 15 |
+
1. THE TRANSFORMATION: Specific weight loss results
|
| 16 |
+
- "Lost 47 lbs In 90 Days"
|
| 17 |
+
- "Down 4 Dress Sizes In 8 Weeks"
|
| 18 |
+
- "From 247 lbs to 168 lbs"
|
| 19 |
+
|
| 20 |
+
WITHOUT NUMBERS (use if no numbers section):
|
| 21 |
+
1. THE ACCUSATION: Direct accusation about weight struggle
|
| 22 |
+
- "Still Overweight?"
|
| 23 |
+
- "Another Failed Diet?"
|
| 24 |
+
- "Tired Of Hiding Your Body?"
|
| 25 |
+
|
| 26 |
+
2. THE CURIOSITY GAP: Open loop about weight loss secret
|
| 27 |
+
- "Thousands Are Losing Weight After THIS"
|
| 28 |
+
- "Doctors Are Prescribing THIS Instead Of Diets"
|
| 29 |
+
- "What Hollywood Has Used For Years"
|
| 30 |
+
|
| 31 |
+
3. THE BEFORE/AFTER: Dramatic transformation proof
|
| 32 |
+
- "Same Person. 90 Days Apart."
|
| 33 |
+
- "Is This Even The Same Person?"
|
| 34 |
+
- "The Transformation That Shocked Everyone"
|
| 35 |
+
|
| 36 |
+
4. THE IDENTITY CALLOUT: Target demographics
|
| 37 |
+
- "Women Over 40: This Changes Everything"
|
| 38 |
+
- "If You've Tried Every Diet And Failed..."
|
| 39 |
+
- "For People Who've Struggled For Years"
|
| 40 |
+
|
| 41 |
+
5. THE MEDICAL AUTHORITY: Doctor/FDA credibility
|
| 42 |
+
- "FDA-Approved Weight Loss"
|
| 43 |
+
- "Doctor-Prescribed. Clinically Proven."
|
| 44 |
+
- "What Doctors Prescribe Their Own Families\""""
|
| 45 |
+
|
| 46 |
+
HEADLINE_FORMULAS_AUTO_INSURANCE = """=== PROVEN WINNING HEADLINE FORMULAS (AUTO INSURANCE) ===
|
| 47 |
+
|
| 48 |
+
WITH NUMBERS (use if numbers section provided):
|
| 49 |
+
1. THE SPECIFIC PRICE ANCHOR: Oddly specific = believable
|
| 50 |
+
- "Car Insurance for as low as $29/month"
|
| 51 |
+
- "Drivers Won't Have To Pay More Than $39 A Month"
|
| 52 |
+
|
| 53 |
+
2. THE BEFORE/AFTER PROOF: Savings with evidence
|
| 54 |
+
- "WAS: $1,842 → NOW: $647"
|
| 55 |
+
- "The Easiest Way To Cut Car Insurance Bills"
|
| 56 |
+
|
| 57 |
+
WITHOUT NUMBERS (use if no numbers section):
|
| 58 |
+
1. THE ACCUSATION: Direct accusation about overpaying
|
| 59 |
+
- "OVERPAYING?"
|
| 60 |
+
- "Still Overpaying For Car Insurance?"
|
| 61 |
+
- "Wasting Money On Auto Insurance?"
|
| 62 |
+
|
| 63 |
+
2. THE CURIOSITY GAP: Open loop that demands click
|
| 64 |
+
- "Drivers Are Ditching Their Auto Insurance & Doing This Instead"
|
| 65 |
+
- "Thousands of drivers are dropping insurance after THIS"
|
| 66 |
+
- "Why Are Drivers Switching?"
|
| 67 |
+
|
| 68 |
+
3. THE IDENTITY CALLOUT: Target demographics (drivers, not "seniors" or "homeowners")
|
| 69 |
+
- "Drivers Over 50: Check Your Eligibility"
|
| 70 |
+
- "Safe Drivers: Check Your Rate"
|
| 71 |
+
|
| 72 |
+
4. THE AUTHORITY TRANSFER: Government/institutional trust
|
| 73 |
+
- "State Program Cuts Insurance Costs"
|
| 74 |
+
- "Official: Safe Drivers Qualify For Reduced Rates"
|
| 75 |
+
|
| 76 |
+
5. THE EMOTIONAL BENEFIT: Focus on outcomes
|
| 77 |
+
- "Protect What Matters Most"
|
| 78 |
+
- "Finally, Peace of Mind On The Road"
|
| 79 |
+
- "Drive Confident Knowing You're Covered\""""
|
| 80 |
+
|
| 81 |
+
HEADLINE_FORMULAS_HOME_INSURANCE = """=== PROVEN WINNING HEADLINE FORMULAS (HOME INSURANCE) ===
|
| 82 |
+
|
| 83 |
+
WITH NUMBERS (use if numbers section provided):
|
| 84 |
+
1. THE SPECIFIC PRICE ANCHOR: Oddly specific = believable
|
| 85 |
+
- "Home Insurance for as low as $97.33/month"
|
| 86 |
+
- "Seniors Won't Have To Pay More Than $49 A Month"
|
| 87 |
+
|
| 88 |
+
2. THE BEFORE/AFTER PROOF: Savings with evidence
|
| 89 |
+
- "WAS: $1,701 → NOW: $583"
|
| 90 |
+
- "The Easiest Way To Cut Home Insurance Bills"
|
| 91 |
+
|
| 92 |
+
WITHOUT NUMBERS (use if no numbers section):
|
| 93 |
+
1. THE ACCUSATION: Direct accusation about overpaying
|
| 94 |
+
- "OVERPAYING?"
|
| 95 |
+
- "Still Underinsured?"
|
| 96 |
+
- "Wasting Money On Insurance?"
|
| 97 |
+
|
| 98 |
+
2. THE CURIOSITY GAP: Open loop that demands click
|
| 99 |
+
- "Seniors Are Ditching Home Insurance & Doing This Instead"
|
| 100 |
+
- "Thousands of homeowners are dropping insurance after THIS"
|
| 101 |
+
- "Why Are Homeowners Switching?"
|
| 102 |
+
|
| 103 |
+
3. THE IDENTITY CALLOUT: Target demographics
|
| 104 |
+
- "Homeowners Over 50: Check Your Eligibility"
|
| 105 |
+
- "Senior homeowners over the age of 50..."
|
| 106 |
+
|
| 107 |
+
4. THE AUTHORITY TRANSFER: Government/institutional trust
|
| 108 |
+
- "State Farm Brings Welfare!"
|
| 109 |
+
- "Sponsored by the US Government"
|
| 110 |
+
|
| 111 |
+
5. THE EMOTIONAL BENEFIT: Focus on outcomes
|
| 112 |
+
- "Protect What Matters Most"
|
| 113 |
+
- "Finally, Peace of Mind"
|
| 114 |
+
- "Sleep Better Knowing You're Covered\""""
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def get_headline_formulas(niche: str, num_type: str) -> str:
|
| 118 |
+
"""Return the headline formulas block for the given niche and number type."""
|
| 119 |
+
if num_type == "weight_loss":
|
| 120 |
+
return HEADLINE_FORMULAS_WEIGHT_LOSS
|
| 121 |
+
if niche == "auto_insurance":
|
| 122 |
+
return HEADLINE_FORMULAS_AUTO_INSURANCE
|
| 123 |
+
return HEADLINE_FORMULAS_HOME_INSURANCE
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def get_numbers_section(
|
| 127 |
+
niche: str,
|
| 128 |
+
num_type: str,
|
| 129 |
+
niche_numbers: Dict[str, str],
|
| 130 |
+
age_bracket: Dict[str, str],
|
| 131 |
+
price_guidance: str,
|
| 132 |
+
) -> str:
|
| 133 |
+
"""Build the numbers guidance section for the copy prompt."""
|
| 134 |
+
if num_type == "weight_loss":
|
| 135 |
+
return f"""=== NUMBERS GUIDANCE (WEIGHT LOSS) ===
|
| 136 |
+
You may include specific numbers if they enhance the ad's believability and fit the format:
|
| 137 |
+
- Starting Weight: {niche_numbers['before']}
|
| 138 |
+
- Current Weight: {niche_numbers['after']}
|
| 139 |
+
- Total Lost: {niche_numbers['difference']}
|
| 140 |
+
- Timeframe: {niche_numbers['days']}
|
| 141 |
+
- Sizes Dropped: {niche_numbers['sizes']}
|
| 142 |
+
- Target Age Bracket: {age_bracket['label']}
|
| 143 |
+
|
| 144 |
+
DECISION: You decide whether to include these numbers based on:
|
| 145 |
+
- The psychological angle (some angles work better with numbers, others without)
|
| 146 |
+
- The psychological strategy (some strategies benefit from specificity, others from emotional appeal)
|
| 147 |
+
- The overall message flow
|
| 148 |
+
|
| 149 |
+
If including numbers: Use them naturally and make them oddly specific (e.g., "47 lbs" not "50 lbs") for believability.
|
| 150 |
+
If NOT including numbers: Focus on emotional transformation, lifestyle benefits, and outcomes without specific metrics."""
|
| 151 |
+
|
| 152 |
+
niche_label = niche.replace("_", " ").upper()
|
| 153 |
+
return f"""=== NUMBERS GUIDANCE ({niche_label}) ===
|
| 154 |
+
You may include specific prices/numbers if they enhance the ad's believability and fit the format:
|
| 155 |
+
- Price Guidance: {price_guidance}
|
| 156 |
+
- Before Price: {niche_numbers['before']}
|
| 157 |
+
- After Price: {niche_numbers['after']}
|
| 158 |
+
- Total Saved: {niche_numbers['difference']}/year
|
| 159 |
+
- Target Age Bracket: {age_bracket['label']}
|
| 160 |
+
|
| 161 |
+
DECISION: You decide whether to include prices/numbers based on:
|
| 162 |
+
- The psychological angle (some angles benefit from prices, others may not)
|
| 163 |
+
- The psychological strategy (some strategies need specificity, others work better emotionally)
|
| 164 |
+
- The overall message flow and what feels most authentic
|
| 165 |
+
|
| 166 |
+
If including prices: Use oddly specific amounts (e.g., "$97.33/month" not "$100/month") for maximum believability.
|
| 167 |
+
If NOT including prices: Focus on emotional benefits, problem-solution framing, curiosity gaps, and trust without specific dollar amounts."""
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def get_trending_section(trending_context: Optional[str]) -> str:
|
| 171 |
+
"""Build the trending topics section when context is provided."""
|
| 172 |
+
if not trending_context:
|
| 173 |
+
return ""
|
| 174 |
+
return f"""
|
| 175 |
+
=== TRENDING TOPICS CONTEXT (INCORPORATE THIS!) ===
|
| 176 |
+
Current Trend: {trending_context}
|
| 177 |
+
|
| 178 |
+
INSTRUCTIONS FOR USING TRENDING TOPICS:
|
| 179 |
+
- Subtly reference or tie the ad message to this trending topic
|
| 180 |
+
- Make the connection feel natural, not forced
|
| 181 |
+
- Use the trend to create urgency or relevance ("Everyone's talking about...")
|
| 182 |
+
- The trend should enhance the hook, not overshadow the core message
|
| 183 |
+
- Examples:
|
| 184 |
+
* "With [trend], now is the perfect time to..."
|
| 185 |
+
* "While everyone's focused on [trend], don't forget about..."
|
| 186 |
+
* "Just like [trend], your [product benefit]..."
|
| 187 |
+
* Reference the trend indirectly in the hook or primary text
|
| 188 |
+
|
| 189 |
+
NOTE: The trend adds timeliness and relevance. Use it strategically!
|
| 190 |
+
"""
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def get_trending_image_guidance(trending_context: Optional[str]) -> str:
|
| 194 |
+
"""Build image-prompt guidance so visuals reflect the current occasion. Returns empty string if no context."""
|
| 195 |
+
if not trending_context or not trending_context.strip():
|
| 196 |
+
return ""
|
| 197 |
+
return f"""
|
| 198 |
+
=== CURRENT OCCASION (reflect in mood and atmosphere) ===
|
| 199 |
+
Occasion: {trending_context.strip()}
|
| 200 |
+
|
| 201 |
+
- Align the image mood and atmosphere with this occasion (e.g. warm and thoughtful for Valentine's/gifts, cozy for holidays, fresh for New Year).
|
| 202 |
+
- Use subtle visual cues: lighting, color warmth, and setting that feel timely and relevant, without literal props unless they fit the ad (e.g. no forced teddy bears or hearts unless the scene naturally calls for it).
|
| 203 |
+
- The image should feel "of the moment" and relevant to the trend, not generic.
|
| 204 |
+
"""
|
services/third_flow.py
CHANGED
|
@@ -141,6 +141,55 @@ class ThirdFlowService:
|
|
| 141 |
print(f"Error in researcher: {e}")
|
| 142 |
return []
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
async def generate_motivators_for_strategy(
|
| 145 |
self,
|
| 146 |
strategy: CreativeStrategies,
|
|
@@ -398,7 +447,8 @@ class ThirdFlowService:
|
|
| 398 |
target_audience: str,
|
| 399 |
offer: str,
|
| 400 |
niche: str = "Home Insurance",
|
| 401 |
-
n: int = 5
|
|
|
|
| 402 |
) -> List[CreativeStrategies]:
|
| 403 |
"""
|
| 404 |
Create creative strategies based on research.
|
|
@@ -411,6 +461,7 @@ class ThirdFlowService:
|
|
| 411 |
offer: Offer to run
|
| 412 |
niche: Niche category
|
| 413 |
n: Number of strategies to generate
|
|
|
|
| 414 |
|
| 415 |
Returns:
|
| 416 |
List of CreativeStrategies
|
|
@@ -422,6 +473,18 @@ class ThirdFlowService:
|
|
| 422 |
f"Concepts: {', '.join(item.concepts)}"
|
| 423 |
for item in researcher_output
|
| 424 |
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
|
| 426 |
messages = [
|
| 427 |
{
|
|
@@ -459,8 +522,9 @@ class ThirdFlowService:
|
|
| 459 |
Niche: {niche}
|
| 460 |
Offer to run: {offer}
|
| 461 |
Target Audience: {target_audience}
|
|
|
|
| 462 |
|
| 463 |
-
Provide the different creative strategies based on the given input."""
|
| 464 |
}
|
| 465 |
]
|
| 466 |
}
|
|
@@ -518,15 +582,19 @@ COLOR PREFERENCE: {niche_guidance_data.get('color_preference', 'balanced')}
|
|
| 518 |
CRITICAL: The image MUST be appropriate for {niche} niche.
|
| 519 |
"""
|
| 520 |
|
| 521 |
-
motivator_block =
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
| 526 |
|
|
|
|
| 527 |
strategy_str = f"""Psychology Trigger: {creative_strategy.phsychologyTrigger}
|
| 528 |
Angle: {creative_strategy.angle}
|
| 529 |
Concept: {creative_strategy.concept}
|
|
|
|
| 530 |
CTA: {creative_strategy.cta}
|
| 531 |
Visual Direction: {creative_strategy.visualDirection}
|
| 532 |
{motivator_block}
|
|
@@ -543,7 +611,9 @@ COLOR PREFERENCE: {niche_guidance_data.get('color_preference', 'balanced')}
|
|
| 543 |
Affiliate marketing is a performance-based model where you promote someone else's product or service and earn a commission for each qualified action (click, lead, or sale).
|
| 544 |
In affiliate marketing 'Low-production, realistic images often outperform studio creatives' runs most.
|
| 545 |
|
| 546 |
-
|
|
|
|
|
|
|
| 547 |
|
| 548 |
{niche_guidance}
|
| 549 |
|
|
@@ -567,7 +637,7 @@ COLOR PREFERENCE: {niche_guidance_data.get('color_preference', 'balanced')}
|
|
| 567 |
"type": "text",
|
| 568 |
"text": f"""Following is the creative strategy:
|
| 569 |
{strategy_str}
|
| 570 |
-
Provide the image prompt
|
| 571 |
}
|
| 572 |
]
|
| 573 |
}
|
|
@@ -651,16 +721,21 @@ COLOR PREFERENCE: {niche_guidance_data.get('color_preference', 'balanced')}
|
|
| 651 |
CopyWriterOutput with title, body, and description
|
| 652 |
"""
|
| 653 |
|
| 654 |
-
motivator_block =
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
|
|
|
|
|
|
| 659 |
|
| 660 |
strategy_str = f"""Psychology Trigger: {creative_strategy.phsychologyTrigger}
|
| 661 |
Angle: {creative_strategy.angle}
|
| 662 |
Concept: {creative_strategy.concept}
|
| 663 |
CTA: {creative_strategy.cta}
|
|
|
|
|
|
|
|
|
|
| 664 |
{motivator_block}
|
| 665 |
"""
|
| 666 |
|
|
@@ -674,6 +749,8 @@ COLOR PREFERENCE: {niche_guidance_data.get('color_preference', 'balanced')}
|
|
| 674 |
The ad copy must include the title, body and description related to the strategies.
|
| 675 |
Affiliate marketing is a performance-based model where you promote someone else's product or service and earn a commission for each qualified action (click, lead, or sale).
|
| 676 |
|
|
|
|
|
|
|
| 677 |
Role of the Title: Stop the scroll and trigger emotion.
|
| 678 |
1. The title is not for explaining. It's for interrupting attention.
|
| 679 |
2. Short titles win because they are scan-friendly.
|
|
@@ -703,7 +780,7 @@ COLOR PREFERENCE: {niche_guidance_data.get('color_preference', 'balanced')}
|
|
| 703 |
"text": f"""Following is the creative strategy:
|
| 704 |
{strategy_str}
|
| 705 |
|
| 706 |
-
Provide the title, body, and description
|
| 707 |
|
| 708 |
IMPORTANT: The body must be 150-250 words long - write a detailed, compelling narrative that tells a story and builds emotional connection with the reader."""
|
| 709 |
}
|
|
|
|
| 141 |
print(f"Error in researcher: {e}")
|
| 142 |
return []
|
| 143 |
|
| 144 |
+
async def generate_motivators_for_research(
|
| 145 |
+
self,
|
| 146 |
+
researcher_output: List[ImageAdEssentials],
|
| 147 |
+
niche: str,
|
| 148 |
+
target_audience: str | None,
|
| 149 |
+
offer: str | None,
|
| 150 |
+
count_per_item: int = 4,
|
| 151 |
+
) -> list[str]:
|
| 152 |
+
"""
|
| 153 |
+
Generate emotional motivators from researcher output for use in strategy making.
|
| 154 |
+
Produces motivators for each research item (psychology trigger + angles + concepts).
|
| 155 |
+
"""
|
| 156 |
+
if not researcher_output:
|
| 157 |
+
return []
|
| 158 |
+
niche_key = niche.lower().replace(" ", "_").replace("-", "_")
|
| 159 |
+
all_motivators: list[str] = []
|
| 160 |
+
seen: set[str] = set()
|
| 161 |
+
for item in researcher_output:
|
| 162 |
+
trigger = item.phsychologyTriggers or ""
|
| 163 |
+
angles = item.angles or []
|
| 164 |
+
concepts = item.concepts or []
|
| 165 |
+
if not angles:
|
| 166 |
+
angles = [trigger or "benefit"]
|
| 167 |
+
if not concepts:
|
| 168 |
+
concepts = ["authentic story"]
|
| 169 |
+
angle_ctx = {
|
| 170 |
+
"name": angles[0],
|
| 171 |
+
"trigger": trigger,
|
| 172 |
+
"example": angles[0] if len(angles) > 0 else "",
|
| 173 |
+
}
|
| 174 |
+
concept_ctx = {
|
| 175 |
+
"name": concepts[0],
|
| 176 |
+
"structure": concepts[0],
|
| 177 |
+
"visual": ", ".join(concepts[:2]) if concepts else "authentic",
|
| 178 |
+
}
|
| 179 |
+
motivators = await generate_motivators(
|
| 180 |
+
niche=niche_key,
|
| 181 |
+
angle=angle_ctx,
|
| 182 |
+
concept=concept_ctx,
|
| 183 |
+
target_audience=target_audience,
|
| 184 |
+
offer=offer,
|
| 185 |
+
count=count_per_item,
|
| 186 |
+
)
|
| 187 |
+
for m in motivators:
|
| 188 |
+
if m and m.strip() and m.strip().lower() not in seen:
|
| 189 |
+
seen.add(m.strip().lower())
|
| 190 |
+
all_motivators.append(m.strip())
|
| 191 |
+
return all_motivators[:24] # Cap total for prompt size
|
| 192 |
+
|
| 193 |
async def generate_motivators_for_strategy(
|
| 194 |
self,
|
| 195 |
strategy: CreativeStrategies,
|
|
|
|
| 447 |
target_audience: str,
|
| 448 |
offer: str,
|
| 449 |
niche: str = "Home Insurance",
|
| 450 |
+
n: int = 5,
|
| 451 |
+
motivators: List[str] | None = None,
|
| 452 |
) -> List[CreativeStrategies]:
|
| 453 |
"""
|
| 454 |
Create creative strategies based on research.
|
|
|
|
| 461 |
offer: Offer to run
|
| 462 |
niche: Niche category
|
| 463 |
n: Number of strategies to generate
|
| 464 |
+
motivators: Emotional motivators (customer's internal voice) to weave into strategies
|
| 465 |
|
| 466 |
Returns:
|
| 467 |
List of CreativeStrategies
|
|
|
|
| 473 |
f"Concepts: {', '.join(item.concepts)}"
|
| 474 |
for item in researcher_output
|
| 475 |
])
|
| 476 |
+
motivators_block = ""
|
| 477 |
+
if motivators:
|
| 478 |
+
motivators_str = "\n".join(f"- {m}" for m in motivators[:20])
|
| 479 |
+
motivators_block = f"""
|
| 480 |
+
EMOTIONAL MOTIVATORS (customer's internal voice - these MUST drive each strategy):
|
| 481 |
+
{motivators_str}
|
| 482 |
+
|
| 483 |
+
CRITICAL: Each strategy MUST be built around ONE motivator. The motivator is the emotional core—everything else (visual direction, title ideas, body ideas, text overlay) must flow from it.
|
| 484 |
+
- visualDirection: Describe a scene that VISUALLY EXPRESSES the motivator (e.g., fear of loss → family protecting documents; trust → community/social proof; unpreparedness → urgency/planning)
|
| 485 |
+
- titleIdeas: Headlines that echo or evoke the motivator's emotional truth
|
| 486 |
+
- text (if any): Overlay text that hints at the motivator without being on-the-nose
|
| 487 |
+
Assign the chosen motivator to the motivators field. The motivator is non-negotiable—it defines the entire creative."""
|
| 488 |
|
| 489 |
messages = [
|
| 490 |
{
|
|
|
|
| 522 |
Niche: {niche}
|
| 523 |
Offer to run: {offer}
|
| 524 |
Target Audience: {target_audience}
|
| 525 |
+
{motivators_block}
|
| 526 |
|
| 527 |
+
Provide the different creative strategies based on the given input. For each strategy, include in the motivators field the motivator(s) that best fit that strategy."""
|
| 528 |
}
|
| 529 |
]
|
| 530 |
}
|
|
|
|
| 582 |
CRITICAL: The image MUST be appropriate for {niche} niche.
|
| 583 |
"""
|
| 584 |
|
| 585 |
+
motivator_block = ""
|
| 586 |
+
if selected_motivator:
|
| 587 |
+
motivator_block = f"""
|
| 588 |
+
PRIMARY DRIVER - CORE MOTIVATOR (the image MUST visually express this emotion):
|
| 589 |
+
"{selected_motivator}"
|
| 590 |
+
|
| 591 |
+
The motivator is the MAIN creative driver. The image must make a viewer FEEL this emotional truth through composition, expressions, setting, and props. Every element in the image should support conveying this motivator. Do NOT produce a generic scene—the scene must be specifically designed to evoke this emotional response."""
|
| 592 |
|
| 593 |
+
text_overlay = creative_strategy.text.textToBeWrittern if creative_strategy.text and creative_strategy.text.textToBeWrittern not in (None, "None", "NA", "") else "No text overlay"
|
| 594 |
strategy_str = f"""Psychology Trigger: {creative_strategy.phsychologyTrigger}
|
| 595 |
Angle: {creative_strategy.angle}
|
| 596 |
Concept: {creative_strategy.concept}
|
| 597 |
+
Text: {text_overlay}
|
| 598 |
CTA: {creative_strategy.cta}
|
| 599 |
Visual Direction: {creative_strategy.visualDirection}
|
| 600 |
{motivator_block}
|
|
|
|
| 611 |
Affiliate marketing is a performance-based model where you promote someone else's product or service and earn a commission for each qualified action (click, lead, or sale).
|
| 612 |
In affiliate marketing 'Low-production, realistic images often outperform studio creatives' runs most.
|
| 613 |
|
| 614 |
+
If a CORE MOTIVATOR is provided, it is the PRIMARY driver. Structure the prompt so the image directly conveys that emotional truth—the scene, expressions, props, and composition must all serve that motivator. A generic family-or-house image is not enough; the motivator must differentiate this creative.
|
| 615 |
+
|
| 616 |
+
For image model here's structure for the prompt: [The Hook - emotion/motivator] + [The Subject] + [The Context/Setting] + [The Technical Polish]
|
| 617 |
|
| 618 |
{niche_guidance}
|
| 619 |
|
|
|
|
| 637 |
"type": "text",
|
| 638 |
"text": f"""Following is the creative strategy:
|
| 639 |
{strategy_str}
|
| 640 |
+
Provide the image prompt. The prompt MUST lead with the motivator's emotion—describe a scene that makes the motivator's feeling unmistakable. Then add subject, setting, and technical polish. Make sure the prompt follows the NICHE-SPECIFIC REQUIREMENTS above."""
|
| 641 |
}
|
| 642 |
]
|
| 643 |
}
|
|
|
|
| 721 |
CopyWriterOutput with title, body, and description
|
| 722 |
"""
|
| 723 |
|
| 724 |
+
motivator_block = ""
|
| 725 |
+
if selected_motivator:
|
| 726 |
+
motivator_block = f"""
|
| 727 |
+
PRIMARY DRIVER - CORE MOTIVATOR (the copy MUST speak directly to this emotional truth):
|
| 728 |
+
"{selected_motivator}"
|
| 729 |
+
|
| 730 |
+
The motivator is the emotional core of the ad. The title should interrupt with this feeling; the body should acknowledge and address it; the description should complete the thought. The reader must feel you understand their internal voice."""
|
| 731 |
|
| 732 |
strategy_str = f"""Psychology Trigger: {creative_strategy.phsychologyTrigger}
|
| 733 |
Angle: {creative_strategy.angle}
|
| 734 |
Concept: {creative_strategy.concept}
|
| 735 |
CTA: {creative_strategy.cta}
|
| 736 |
+
Title Ideas: {creative_strategy.titleIdeas}
|
| 737 |
+
Caption Ideas: {creative_strategy.captionIdeas}
|
| 738 |
+
Body Ideas: {creative_strategy.bodyIdeas}
|
| 739 |
{motivator_block}
|
| 740 |
"""
|
| 741 |
|
|
|
|
| 749 |
The ad copy must include the title, body and description related to the strategies.
|
| 750 |
Affiliate marketing is a performance-based model where you promote someone else's product or service and earn a commission for each qualified action (click, lead, or sale).
|
| 751 |
|
| 752 |
+
If a CORE MOTIVATOR is provided, it is the PRIMARY driver. The title should evoke that motivator; the body must speak directly to that emotional truth and address it; the description should complete the thought. The motivator differentiates this ad—do not write generic copy.
|
| 753 |
+
|
| 754 |
Role of the Title: Stop the scroll and trigger emotion.
|
| 755 |
1. The title is not for explaining. It's for interrupting attention.
|
| 756 |
2. Short titles win because they are scan-friendly.
|
|
|
|
| 780 |
"text": f"""Following is the creative strategy:
|
| 781 |
{strategy_str}
|
| 782 |
|
| 783 |
+
Provide the title, body, and description. If a CORE MOTIVATOR is present, center the copy on it—the title should make someone with that thought pause; the body should speak to that specific emotional truth; the description should extend it. Do not write generic insurance copy; the motivator must be unmistakably reflected.
|
| 784 |
|
| 785 |
IMPORTANT: The body must be 150-250 words long - write a detailed, compelling narrative that tells a story and builds emotional connection with the reader."""
|
| 786 |
}
|
services/trend_monitor.py
CHANGED
|
@@ -1,276 +1,141 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
Fetches and analyzes relevant news for Home Insurance and GLP-1 niches
|
| 4 |
"""
|
| 5 |
|
| 6 |
from gnews import GNews
|
| 7 |
-
from typing import List, Dict,
|
| 8 |
from datetime import datetime, timedelta
|
| 9 |
import asyncio
|
| 10 |
|
| 11 |
-
|
|
|
|
| 12 |
NICHE_KEYWORDS = {
|
| 13 |
-
"home_insurance": [
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
"insurance rates", "coverage", "home protection"
|
| 17 |
-
],
|
| 18 |
-
"glp1": [
|
| 19 |
-
"GLP-1", "Ozempic", "Wegovy", "Mounjaro", "Zepbound",
|
| 20 |
-
"weight loss", "diabetes", "semaglutide", "tirzepatide",
|
| 21 |
-
"weight loss drug", "obesity treatment"
|
| 22 |
-
],
|
| 23 |
-
"auto_insurance": [
|
| 24 |
-
"auto insurance", "car insurance", "vehicle insurance",
|
| 25 |
-
"driving safety", "accident rates", "insurance premiums",
|
| 26 |
-
"car insurance rates", "driver discounts", "vehicle coverage"
|
| 27 |
-
]
|
| 28 |
}
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
class TrendMonitor:
|
| 36 |
-
"""Monitor Google News for trending topics relevant to ad generation"""
|
| 37 |
-
|
| 38 |
def __init__(self, language: str = "en", country: str = "US"):
|
| 39 |
self.google_news = GNews(language=language, country=country)
|
| 40 |
-
|
| 41 |
-
self.google_news.period = '7d'
|
| 42 |
self.google_news.max_results = 10
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
async def fetch_trends(self, niche: str) -> List[Dict]:
|
| 45 |
-
"""
|
| 46 |
-
Fetch trending news for a specific niche with caching
|
| 47 |
-
|
| 48 |
-
Args:
|
| 49 |
-
niche: Target niche (home_insurance or glp1)
|
| 50 |
-
|
| 51 |
-
Returns:
|
| 52 |
-
List of trend dicts with title, description, date, url, relevance_score
|
| 53 |
-
"""
|
| 54 |
cache_key = f"trends_{niche}"
|
| 55 |
-
|
| 56 |
-
# Check cache
|
| 57 |
if cache_key in TREND_CACHE:
|
| 58 |
-
|
| 59 |
-
if datetime.now() -
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
# Fetch fresh data
|
| 64 |
-
print(f"🔍 Fetching fresh trends for {niche}...")
|
| 65 |
-
trends = await self._fetch_trends_uncached(niche)
|
| 66 |
-
|
| 67 |
-
# Update cache
|
| 68 |
TREND_CACHE[cache_key] = (trends, datetime.now())
|
| 69 |
-
|
| 70 |
return trends
|
| 71 |
-
|
| 72 |
-
async def
|
| 73 |
-
"""
|
| 74 |
-
Fetch trending news without caching
|
| 75 |
-
|
| 76 |
-
Args:
|
| 77 |
-
niche: Target niche
|
| 78 |
-
|
| 79 |
-
Returns:
|
| 80 |
-
List of scored and ranked articles
|
| 81 |
-
"""
|
| 82 |
if niche not in NICHE_KEYWORDS:
|
| 83 |
-
raise ValueError(f"Unsupported niche: {niche}.
|
| 84 |
-
|
| 85 |
-
keywords = NICHE_KEYWORDS[niche]
|
| 86 |
all_articles = []
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
for keyword in keywords[:3]: # Top 3 keywords
|
| 90 |
try:
|
| 91 |
-
# Run synchronous GNews call in thread pool
|
| 92 |
-
loop = asyncio.get_event_loop()
|
| 93 |
articles = await loop.run_in_executor(
|
| 94 |
-
None,
|
| 95 |
-
lambda: self.google_news.get_news(keyword)
|
| 96 |
)
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
article['niche'] = niche
|
| 102 |
-
all_articles.append(article)
|
| 103 |
-
|
| 104 |
except Exception as e:
|
| 105 |
-
print(f"⚠️
|
| 106 |
-
continue
|
| 107 |
-
|
| 108 |
if not all_articles:
|
| 109 |
-
print(f"⚠️ No articles found for {niche}")
|
| 110 |
return []
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
print(f"✓ Found {len(scored_articles)} articles for {niche}")
|
| 116 |
-
|
| 117 |
-
# Return top 5 most relevant
|
| 118 |
-
return scored_articles[:5]
|
| 119 |
-
|
| 120 |
-
def _score_relevance(self, articles: List[Dict], niche: str) -> List[Dict]:
|
| 121 |
-
"""
|
| 122 |
-
Score articles by relevance to niche
|
| 123 |
-
|
| 124 |
-
Args:
|
| 125 |
-
articles: List of article dicts
|
| 126 |
-
niche: Target niche
|
| 127 |
-
|
| 128 |
-
Returns:
|
| 129 |
-
Sorted list with relevance_score added
|
| 130 |
-
"""
|
| 131 |
keywords = NICHE_KEYWORDS[niche]
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
score =
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
# Keyword matching (more matches = higher score)
|
| 138 |
-
for keyword in keywords:
|
| 139 |
-
if keyword.lower() in text:
|
| 140 |
-
score += 2
|
| 141 |
-
|
| 142 |
-
# Recency bonus (newer = better)
|
| 143 |
-
pub_date = article.get('published date')
|
| 144 |
-
if pub_date:
|
| 145 |
try:
|
| 146 |
-
|
| 147 |
-
if isinstance(
|
| 148 |
-
|
| 149 |
-
else
|
| 150 |
-
|
| 151 |
-
from email.utils import parsedate_to_datetime
|
| 152 |
-
try:
|
| 153 |
-
pub_date_obj = parsedate_to_datetime(str(pub_date))
|
| 154 |
-
except:
|
| 155 |
-
# Fallback to ISO format
|
| 156 |
-
pub_date_obj = datetime.fromisoformat(str(pub_date))
|
| 157 |
-
days_old = (datetime.now() - pub_date_obj).days
|
| 158 |
-
|
| 159 |
-
if days_old <= 1:
|
| 160 |
-
score += 5 # Hot news
|
| 161 |
-
elif days_old <= 3:
|
| 162 |
-
score += 3
|
| 163 |
-
elif days_old <= 7:
|
| 164 |
-
score += 1
|
| 165 |
-
except Exception as e:
|
| 166 |
-
# Silently skip date parsing errors
|
| 167 |
pass
|
| 168 |
-
|
| 169 |
-
# Emotion triggers (fear, urgency, transformation)
|
| 170 |
-
emotion_words = [
|
| 171 |
-
'crisis', 'warning', 'breakthrough', 'new', 'record',
|
| 172 |
-
'shortage', 'surge', 'dramatic', 'shocking', 'urgent',
|
| 173 |
-
'breaking', 'exclusive', 'major', 'critical'
|
| 174 |
-
]
|
| 175 |
-
for word in emotion_words:
|
| 176 |
if word in text:
|
| 177 |
score += 1
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
| 204 |
async def get_trending_angles(self, niche: str) -> List[Dict]:
|
| 205 |
-
"""
|
| 206 |
-
Generate angle suggestions based on current trends
|
| 207 |
-
|
| 208 |
-
Args:
|
| 209 |
-
niche: Target niche
|
| 210 |
-
|
| 211 |
-
Returns:
|
| 212 |
-
List of angle dicts compatible with angle × concept system
|
| 213 |
-
"""
|
| 214 |
trends = await self.fetch_trends(niche)
|
| 215 |
-
|
| 216 |
angles = []
|
| 217 |
-
for
|
| 218 |
-
title =
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
# Analyze trend for psychological trigger
|
| 222 |
-
trigger = self._detect_trigger(title, trend.get('description', ''))
|
| 223 |
-
|
| 224 |
-
angle = {
|
| 225 |
"key": f"trend_{abs(hash(title)) % 10000}",
|
| 226 |
"name": f"Trending: {title[:40]}...",
|
| 227 |
-
"trigger":
|
| 228 |
-
"example":
|
| 229 |
"category": "Trending",
|
| 230 |
"source": "google_news",
|
| 231 |
-
"url":
|
| 232 |
"expires": (datetime.now() + timedelta(days=7)).isoformat(),
|
| 233 |
-
"relevance_score":
|
| 234 |
-
}
|
| 235 |
-
angles.append(angle)
|
| 236 |
-
|
| 237 |
return angles
|
| 238 |
-
|
| 239 |
-
def
|
| 240 |
-
"""
|
| 241 |
-
Detect psychological trigger from news content
|
| 242 |
-
|
| 243 |
-
Args:
|
| 244 |
-
title: Article title
|
| 245 |
-
description: Article description
|
| 246 |
-
|
| 247 |
-
Returns:
|
| 248 |
-
Trigger name (Fear, Hope, FOMO, etc.)
|
| 249 |
-
"""
|
| 250 |
-
text = f"{title} {description}".lower()
|
| 251 |
-
|
| 252 |
-
# Trigger detection rules (ordered by priority)
|
| 253 |
-
if any(word in text for word in ['crisis', 'warning', 'danger', 'risk', 'threat', 'disaster']):
|
| 254 |
-
return "Fear"
|
| 255 |
-
elif any(word in text for word in ['shortage', 'limited', 'running out', 'exclusive', 'sold out']):
|
| 256 |
-
return "FOMO"
|
| 257 |
-
elif any(word in text for word in ['breakthrough', 'solution', 'cure', 'relief', 'success']):
|
| 258 |
-
return "Hope"
|
| 259 |
-
elif any(word in text for word in ['save', 'discount', 'cheaper', 'affordable', 'deal']):
|
| 260 |
-
return "Greed"
|
| 261 |
-
elif any(word in text for word in ['new', 'innovation', 'discover', 'reveal', 'secret']):
|
| 262 |
-
return "Curiosity"
|
| 263 |
-
elif any(word in text for word in ['urgent', 'now', 'immediate', 'breaking']):
|
| 264 |
-
return "Urgency"
|
| 265 |
-
else:
|
| 266 |
-
return "Emotion"
|
| 267 |
-
|
| 268 |
-
def clear_cache(self):
|
| 269 |
-
"""Clear the trend cache (useful for testing)"""
|
| 270 |
global TREND_CACHE
|
| 271 |
TREND_CACHE = {}
|
| 272 |
-
print("✓ Trend cache cleared")
|
| 273 |
|
| 274 |
|
| 275 |
-
# Global instance
|
| 276 |
trend_monitor = TrendMonitor()
|
|
|
|
| 1 |
"""
|
| 2 |
+
Trend monitor: current occasions (AI) + niche news (GNews) for ad trending context.
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from gnews import GNews
|
| 6 |
+
from typing import List, Dict, Any
|
| 7 |
from datetime import datetime, timedelta
|
| 8 |
import asyncio
|
| 9 |
|
| 10 |
+
from services.current_occasions import get_current_occasions
|
| 11 |
+
|
| 12 |
NICHE_KEYWORDS = {
|
| 13 |
+
"home_insurance": ["home insurance", "homeowners insurance", "property insurance", "natural disaster", "insurance rates"],
|
| 14 |
+
"glp1": ["GLP-1", "Ozempic", "Wegovy", "weight loss", "Mounjaro", "Zepbound"],
|
| 15 |
+
"auto_insurance": ["auto insurance", "car insurance", "vehicle insurance", "insurance premiums", "driver discounts"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
+
TREND_CACHE: Dict[str, tuple] = {}
|
| 19 |
+
CACHE_DURATION = timedelta(hours=1)
|
| 20 |
+
|
| 21 |
+
TRIGGER_WORDS = [
|
| 22 |
+
(["crisis", "warning", "danger", "risk", "threat", "disaster"], "Fear"),
|
| 23 |
+
(["shortage", "limited", "running out", "exclusive", "sold out"], "FOMO"),
|
| 24 |
+
(["breakthrough", "solution", "cure", "relief", "success"], "Hope"),
|
| 25 |
+
(["save", "discount", "cheaper", "affordable", "deal"], "Greed"),
|
| 26 |
+
(["new", "innovation", "discover", "reveal", "secret"], "Curiosity"),
|
| 27 |
+
(["urgent", "now", "immediate", "breaking"], "Urgency"),
|
| 28 |
+
]
|
| 29 |
|
| 30 |
|
| 31 |
class TrendMonitor:
|
|
|
|
|
|
|
| 32 |
def __init__(self, language: str = "en", country: str = "US"):
|
| 33 |
self.google_news = GNews(language=language, country=country)
|
| 34 |
+
self.google_news.period = "7d"
|
|
|
|
| 35 |
self.google_news.max_results = 10
|
| 36 |
+
|
| 37 |
+
def _normalize_niche(self, niche: str) -> str:
|
| 38 |
+
key = niche.lower().strip().replace(" ", "_")
|
| 39 |
+
return key if key in NICHE_KEYWORDS else niche
|
| 40 |
+
|
| 41 |
async def fetch_trends(self, niche: str) -> List[Dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
cache_key = f"trends_{niche}"
|
|
|
|
|
|
|
| 43 |
if cache_key in TREND_CACHE:
|
| 44 |
+
data, cached_at = TREND_CACHE[cache_key]
|
| 45 |
+
if datetime.now() - cached_at < CACHE_DURATION:
|
| 46 |
+
return data
|
| 47 |
+
trends = await self._fetch_news(niche)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
TREND_CACHE[cache_key] = (trends, datetime.now())
|
|
|
|
| 49 |
return trends
|
| 50 |
+
|
| 51 |
+
async def _fetch_news(self, niche: str) -> List[Dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
if niche not in NICHE_KEYWORDS:
|
| 53 |
+
raise ValueError(f"Unsupported niche: {niche}. Use: {list(NICHE_KEYWORDS.keys())}")
|
| 54 |
+
keywords = NICHE_KEYWORDS[niche][:3]
|
|
|
|
| 55 |
all_articles = []
|
| 56 |
+
loop = asyncio.get_event_loop()
|
| 57 |
+
for kw in keywords:
|
|
|
|
| 58 |
try:
|
|
|
|
|
|
|
| 59 |
articles = await loop.run_in_executor(
|
| 60 |
+
None, lambda k=kw: self.google_news.get_news(k)
|
|
|
|
| 61 |
)
|
| 62 |
+
for a in articles:
|
| 63 |
+
a["keyword"] = kw
|
| 64 |
+
a["niche"] = niche
|
| 65 |
+
all_articles.append(a)
|
|
|
|
|
|
|
|
|
|
| 66 |
except Exception as e:
|
| 67 |
+
print(f"⚠️ News fetch failed for '{kw}': {e}")
|
|
|
|
|
|
|
| 68 |
if not all_articles:
|
|
|
|
| 69 |
return []
|
| 70 |
+
scored = self._score_articles(all_articles, niche)
|
| 71 |
+
return scored[:5]
|
| 72 |
+
|
| 73 |
+
def _score_articles(self, articles: List[Dict], niche: str) -> List[Dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
keywords = NICHE_KEYWORDS[niche]
|
| 75 |
+
for a in articles:
|
| 76 |
+
text = f"{a.get('title', '')} {a.get('description', '')}".lower()
|
| 77 |
+
score = sum(2 for k in keywords if k.lower() in text)
|
| 78 |
+
pub = a.get("published date")
|
| 79 |
+
if pub:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
try:
|
| 81 |
+
from email.utils import parsedate_to_datetime
|
| 82 |
+
dt = parsedate_to_datetime(str(pub)) if not isinstance(pub, datetime) else pub
|
| 83 |
+
days = (datetime.now() - dt).days
|
| 84 |
+
score += 5 if days <= 1 else 3 if days <= 3 else 1
|
| 85 |
+
except Exception:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
pass
|
| 87 |
+
for word in ["crisis", "warning", "breakthrough", "new", "urgent", "breaking", "major"]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
if word in text:
|
| 89 |
score += 1
|
| 90 |
+
a["relevance_score"] = score
|
| 91 |
+
return sorted(articles, key=lambda x: x.get("relevance_score", 0), reverse=True)
|
| 92 |
+
|
| 93 |
+
def _trigger(self, title: str, description: str) -> str:
|
| 94 |
+
text = f"{title} {description}".lower()
|
| 95 |
+
for words, trigger in TRIGGER_WORDS:
|
| 96 |
+
if any(w in text for w in words):
|
| 97 |
+
return trigger
|
| 98 |
+
return "Emotion"
|
| 99 |
+
|
| 100 |
+
async def get_relevant_trends_for_niche(self, niche: str) -> Dict[str, Any]:
|
| 101 |
+
relevant = []
|
| 102 |
+
for occ in await get_current_occasions():
|
| 103 |
+
relevant.append({"title": occ["title"], "summary": occ["summary"], "category": occ.get("category", "Occasion")})
|
| 104 |
+
niche_key = self._normalize_niche(niche)
|
| 105 |
+
if niche_key in NICHE_KEYWORDS:
|
| 106 |
+
try:
|
| 107 |
+
for t in (await self.fetch_trends(niche_key))[:3]:
|
| 108 |
+
desc = t.get("description", "") or t.get("title", "")
|
| 109 |
+
relevant.append({
|
| 110 |
+
"title": t.get("title", ""),
|
| 111 |
+
"summary": desc,
|
| 112 |
+
"category": t.get("niche", "News").replace("_", " ").title(),
|
| 113 |
+
})
|
| 114 |
+
except Exception as e:
|
| 115 |
+
print(f"⚠️ News trends skipped: {e}")
|
| 116 |
+
return {"relevant_trends": relevant}
|
| 117 |
+
|
| 118 |
async def get_trending_angles(self, niche: str) -> List[Dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
trends = await self.fetch_trends(niche)
|
|
|
|
| 120 |
angles = []
|
| 121 |
+
for t in trends[:3]:
|
| 122 |
+
title = t.get("title", "")
|
| 123 |
+
angles.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
"key": f"trend_{abs(hash(title)) % 10000}",
|
| 125 |
"name": f"Trending: {title[:40]}...",
|
| 126 |
+
"trigger": self._trigger(title, t.get("description", "")),
|
| 127 |
+
"example": (t.get("description") or title)[:100],
|
| 128 |
"category": "Trending",
|
| 129 |
"source": "google_news",
|
| 130 |
+
"url": t.get("url"),
|
| 131 |
"expires": (datetime.now() + timedelta(days=7)).isoformat(),
|
| 132 |
+
"relevance_score": t.get("relevance_score", 0),
|
| 133 |
+
})
|
|
|
|
|
|
|
| 134 |
return angles
|
| 135 |
+
|
| 136 |
+
def clear_cache(self) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
global TREND_CACHE
|
| 138 |
TREND_CACHE = {}
|
|
|
|
| 139 |
|
| 140 |
|
|
|
|
| 141 |
trend_monitor = TrendMonitor()
|