sushilideaclan01 commited on
Commit
d4a4da7
·
1 Parent(s): 8cab861

refactored the files

Browse files
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": ["before_after", "testimonial", "lifestyle", "authority", "problem_solution"],
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
- # Add remaining frameworks for variety
1009
- all_keys = preferred_keys + [k for k in FRAMEWORKS.keys() if k not in preferred_keys]
 
 
 
 
1010
 
1011
- # Select count frameworks with some randomization
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 Section - COMING SOON */}
170
  <div className="border-t border-gray-200 pt-4">
171
  <div className="flex items-center justify-between mb-3">
172
- <div className="opacity-50">
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
- Incorporate current affairs and news for increased relevance
178
  </p>
179
  </div>
180
- <div className="flex items-center gap-2">
181
- <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
182
- Coming Soon
183
- </span>
184
- <label className="relative inline-flex items-center cursor-not-allowed opacity-50">
185
- <input
186
- type="checkbox"
187
- className="sr-only peer"
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
- Main Ad Generator Service
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
- # IMPORTS
10
- # ============================================================================
 
11
 
12
- # Standard library
 
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 Dict, Any, List, Optional
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 services.llm import llm_service
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # CONSTANTS
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 high-converting ad creatives using psychological triggers.
123
- Uses maximum randomization to ensure different results every time.
124
- Implements professional prompting techniques.
 
 
 
 
 
 
 
 
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
- # PROMPT BUILDING METHODS
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
- # Build numbers section from niche number_config type (data-driven)
588
  num_type = niche_data.get("number_config", {}).get("type", "savings")
589
- if num_type == "weight_loss":
590
- numbers_section = f"""=== NUMBERS GUIDANCE (WEIGHT LOSS) ===
591
- You may include specific numbers if they enhance the ad's believability and fit the format:
592
- - Starting Weight: {niche_numbers['before']}
593
- - Current Weight: {niche_numbers['after']}
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 HOOK EXAMPLES: {', '.join(framework_hooks[:3]) if framework_hooks else 'N/A'}
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: Use VARIETY - show different 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.
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; people and cars optional)
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 asyncio.to_thread(
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 3: Creative Director
 
 
 
 
 
 
 
 
 
 
 
 
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
- # MATRIX-SPECIFIC PROMPT METHODS
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
- f"\nCORE MOTIVATOR (emotional driver): {selected_motivator}\n"
523
- if selected_motivator
524
- else ""
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
- For nano banana image model here's structure for the prompt: [The Hook] + [The Subject] + [The Context/Setting] + [The Technical Polish]
 
 
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 for the given creative strategy. Make sure the prompt follows the NICHE-SPECIFIC REQUIREMENTS above."""
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
- f"\nCORE MOTIVATOR (customer’s internal voice): {selected_motivator}\n"
656
- if selected_motivator
657
- else ""
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 for the given creative strategy.
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
- Google News Trend Monitor for PsyAdGenesis
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, Optional
8
  from datetime import datetime, timedelta
9
  import asyncio
10
 
11
- # Niche-specific keywords
 
12
  NICHE_KEYWORDS = {
13
- "home_insurance": [
14
- "home insurance", "homeowners insurance", "property insurance",
15
- "natural disaster", "hurricane", "wildfire", "flood damage",
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
- # Simple in-memory cache
31
- TREND_CACHE = {}
32
- CACHE_DURATION = timedelta(hours=1) # Cache for 1 hour
 
 
 
 
 
 
 
 
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
- # Set period to last 7 days for freshness
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
- cached_data, cached_time = TREND_CACHE[cache_key]
59
- if datetime.now() - cached_time < CACHE_DURATION:
60
- print(f"✓ Using cached trends for {niche}")
61
- return cached_data
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 _fetch_trends_uncached(self, niche: str) -> List[Dict]:
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}. Supported: {list(NICHE_KEYWORDS.keys())}")
84
-
85
- keywords = NICHE_KEYWORDS[niche]
86
  all_articles = []
87
-
88
- # Fetch articles for each keyword (limit to avoid rate limits)
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
- for article in articles:
99
- # Add metadata
100
- article['keyword'] = keyword
101
- article['niche'] = niche
102
- all_articles.append(article)
103
-
104
  except Exception as e:
105
- print(f"⚠️ Error fetching news for '{keyword}': {e}")
106
- continue
107
-
108
  if not all_articles:
109
- print(f"⚠️ No articles found for {niche}")
110
  return []
111
-
112
- # Score and rank by relevance
113
- scored_articles = self._score_relevance(all_articles, niche)
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
- for article in articles:
134
- score = 0
135
- text = f"{article.get('title', '')} {article.get('description', '')}".lower()
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
- # Handle datetime object
147
- if isinstance(pub_date, datetime):
148
- days_old = (datetime.now() - pub_date).days
149
- else:
150
- # Try parsing RFC 2822 format first (from RSS feeds)
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
- article['relevance_score'] = score
180
-
181
- # Sort by score descending
182
- return sorted(articles, key=lambda x: x.get('relevance_score', 0), reverse=True)
183
-
184
- def extract_trend_context(self, article: Dict) -> str:
185
- """
186
- Extract a concise trend context for prompt injection
187
-
188
- Args:
189
- article: Article dict
190
-
191
- Returns:
192
- Concise context string
193
- """
194
- title = article.get('title', '')
195
- description = article.get('description', '')
196
-
197
- # Create a concise context string
198
- context = f"{title}"
199
- if description and len(description) < 150:
200
- context += f" - {description}"
201
-
202
- return context.strip()
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 trend in trends[:3]: # Top 3 trends
218
- title = trend.get('title', '')
219
- context = self.extract_trend_context(trend)
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": trigger,
228
- "example": context[:100],
229
  "category": "Trending",
230
  "source": "google_news",
231
- "url": trend.get('url'),
232
  "expires": (datetime.now() + timedelta(days=7)).isoformat(),
233
- "relevance_score": trend.get('relevance_score', 0)
234
- }
235
- angles.append(angle)
236
-
237
  return angles
238
-
239
- def _detect_trigger(self, title: str, description: str) -> str:
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()