sushilideaclan01 commited on
Commit
c5771b6
·
1 Parent(s): 3c7508e

feat: Enhance ad generation with trending topics integration and bulk export functionality

Browse files

- Updated API endpoints to include optional parameters for trending topics in ad generation requests.
- Added new endpoint for bulk exporting ads as a ZIP file containing images and an Excel sheet.
- Implemented trend monitoring service to fetch relevant news articles for specified niches.
- Enhanced validation schemas to accommodate new parameters for trending topics.
- Updated generator service to incorporate trending context into ad copy generation.
- Created export service for handling bulk ad exports, including image downloads and Excel file creation.
- Added caching mechanism for trending topics to optimize performance.

data/containers.py CHANGED
@@ -63,24 +63,24 @@ CONTAINER_TYPES: Dict[str, Dict[str, Any]] = {
63
  "Include carrier/time info",
64
  ],
65
  },
66
- "bank_alert": {
67
- "name": "Bank Alert",
68
- "description": "Bank transaction notification style",
69
- "visual_guidance": "Bank transaction notification style, red alert box, bank app UI, urgent notification aesthetic",
70
- "font_style": "Arial, Helvetica, system font",
71
- "colors": {
72
- "primary": "#D32F2F", # Alert red
73
- "secondary": "#1976D2", # Bank blue
74
- "background": "#FFFFFF",
75
- "text": "#212121",
76
- },
77
- "best_for": ["financial urgency", "savings alerts", "money-related offers"],
78
- "authenticity_tips": [
79
- "Use official-looking format",
80
- "Include dollar amounts",
81
- "Add bank-style icons",
82
- ],
83
- },
84
  "news_chyron": {
85
  "name": "News Chyron",
86
  "description": "Breaking news ticker style",
 
63
  "Include carrier/time info",
64
  ],
65
  },
66
+ # "bank_alert": {
67
+ # "name": "Bank Alert",
68
+ # "description": "Bank transaction notification style",
69
+ # "visual_guidance": "Bank transaction notification style, red alert box, bank app UI, urgent notification aesthetic",
70
+ # "font_style": "Arial, Helvetica, system font",
71
+ # "colors": {
72
+ # "primary": "#D32F2F", # Alert red
73
+ # "secondary": "#1976D2", # Bank blue
74
+ # "background": "#FFFFFF",
75
+ # "text": "#212121",
76
+ # },
77
+ # "best_for": ["financial urgency", "savings alerts", "money-related offers"],
78
+ # "authenticity_tips": [
79
+ # "Use official-looking format",
80
+ # "Include dollar amounts",
81
+ # "Add bank-style icons",
82
+ # ],
83
+ # },
84
  "news_chyron": {
85
  "name": "News Chyron",
86
  "description": "Breaking news ticker style",
frontend/app/gallery/page.tsx CHANGED
@@ -5,7 +5,7 @@ import { GalleryGrid } from "@/components/gallery/GalleryGrid";
5
  import { FilterBar } from "@/components/gallery/FilterBar";
6
  import { Button } from "@/components/ui/Button";
7
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
8
- import { listAds, deleteAd } from "@/lib/api/endpoints";
9
  import { useGalleryStore } from "@/store/galleryStore";
10
  import { toast } from "react-hot-toast";
11
  import { Download, Trash2, CheckSquare, Square, ArrowUpDown } from "lucide-react";
@@ -150,6 +150,42 @@ export default function GalleryPage() {
150
  }
151
  };
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  const handlePageChange = (newOffset: number) => {
154
  setOffset(newOffset);
155
  window.scrollTo({ top: 0, behavior: "smooth" });
@@ -185,6 +221,12 @@ export default function GalleryPage() {
185
  <span>Deselect ({selectedAds.length})</span>
186
  </div>
187
  </Button>
 
 
 
 
 
 
188
  <Button variant="danger" size="sm" onClick={handleBulkDelete}>
189
  <div className="flex items-center gap-2">
190
  <Trash2 className="h-4 w-4" />
 
5
  import { FilterBar } from "@/components/gallery/FilterBar";
6
  import { Button } from "@/components/ui/Button";
7
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
8
+ import { listAds, deleteAd, exportBulkAds } from "@/lib/api/endpoints";
9
  import { useGalleryStore } from "@/store/galleryStore";
10
  import { toast } from "react-hot-toast";
11
  import { Download, Trash2, CheckSquare, Square, ArrowUpDown } from "lucide-react";
 
150
  }
151
  };
152
 
153
+ const handleBulkExport = async () => {
154
+ if (selectedAds.length === 0) return;
155
+
156
+ if (selectedAds.length > 50) {
157
+ toast.error("Maximum 50 ads can be exported at once");
158
+ return;
159
+ }
160
+
161
+ const exportToast = toast.loading(`Preparing export for ${selectedAds.length} ad(s)...`);
162
+
163
+ try {
164
+ // Call the export API
165
+ const blob = await exportBulkAds(selectedAds);
166
+
167
+ // Create download link
168
+ const url = window.URL.createObjectURL(blob);
169
+ const link = document.createElement("a");
170
+ link.href = url;
171
+
172
+ // Generate filename with timestamp
173
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
174
+ link.download = `creatives_export_${timestamp}.zip`;
175
+
176
+ document.body.appendChild(link);
177
+ link.click();
178
+ document.body.removeChild(link);
179
+ window.URL.revokeObjectURL(url);
180
+
181
+ toast.success(`Successfully exported ${selectedAds.length} ad(s)`, { id: exportToast });
182
+ clearSelection();
183
+ } catch (error: any) {
184
+ console.error("Export error:", error);
185
+ toast.error(error.response?.data?.detail || "Failed to export ads", { id: exportToast });
186
+ }
187
+ };
188
+
189
  const handlePageChange = (newOffset: number) => {
190
  setOffset(newOffset);
191
  window.scrollTo({ top: 0, behavior: "smooth" });
 
221
  <span>Deselect ({selectedAds.length})</span>
222
  </div>
223
  </Button>
224
+ <Button variant="primary" size="sm" onClick={handleBulkExport}>
225
+ <div className="flex items-center gap-2">
226
+ <Download className="h-4 w-4" />
227
+ <span>Export Selected</span>
228
+ </div>
229
+ </Button>
230
  <Button variant="danger" size="sm" onClick={handleBulkDelete}>
231
  <div className="flex items-center gap-2">
232
  <Trash2 className="h-4 w-4" />
frontend/app/generate/page.tsx CHANGED
@@ -208,11 +208,17 @@ export default function GeneratePage() {
208
  }
209
  };
210
 
211
- const handleStandardGenerate = async (data: { niche: Niche; num_images: number; image_model?: string | null; target_audience?: string; offer?: string }) => {
212
  reset();
213
  setIsGenerating(true);
214
  setGenerationStartTime(Date.now());
215
 
 
 
 
 
 
 
216
  // If num_images > 1, generate batch of ads
217
  if (data.num_images > 1) {
218
  setBatchCount(data.num_images);
@@ -229,13 +235,13 @@ export default function GeneratePage() {
229
  try {
230
  // Use batch generation for multiple ads with standard method
231
  const batchResponse = await generateBatch({
232
- niche: data.niche,
233
- count: data.num_images,
234
  images_per_ad: 1, // Each ad gets 1 image
235
- image_model: data.image_model,
236
  method: "standard", // Use standard method only
237
- target_audience: data.target_audience,
238
- offer: data.offer,
239
  });
240
  const results = batchResponse.ads;
241
 
@@ -289,10 +295,8 @@ export default function GeneratePage() {
289
 
290
  // Generate single ad with 1 image
291
  const result = await generateAd({
292
- ...data,
293
  num_images: 1,
294
- target_audience: data.target_audience,
295
- offer: data.offer,
296
  });
297
 
298
  clearInterval(progressInterval);
@@ -364,8 +368,8 @@ export default function GeneratePage() {
364
  custom_concept: useCustomConcept ? JSON.stringify(effectiveConcept) : null,
365
  num_images: 1,
366
  image_model: imageModel,
367
- target_audience: targetAudience || undefined,
368
- offer: offer || undefined,
369
  core_motivator: motivator,
370
  });
371
  })
@@ -410,18 +414,18 @@ export default function GeneratePage() {
410
  try {
411
  const motivator =
412
  selectedMotivators.length > 0 ? selectedMotivators[0] : undefined;
413
- const result = await generateMatrixAd({
414
- niche,
415
- angle_key: useCustomAngle ? "custom" : effectiveAngle.key,
416
- concept_key: useCustomConcept ? "custom" : effectiveConcept.key,
417
- custom_angle: useCustomAngle ? JSON.stringify(effectiveAngle) : null,
418
- custom_concept: useCustomConcept ? JSON.stringify(effectiveConcept) : null,
419
- num_images: 1,
420
- image_model: imageModel,
421
- target_audience: targetAudience || undefined,
422
- offer: offer || undefined,
423
- core_motivator: motivator,
424
- });
425
 
426
  setCurrentGeneration(result);
427
  setProgress({
@@ -445,10 +449,16 @@ export default function GeneratePage() {
445
  }
446
  };
447
 
448
- const handleBatchGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null; target_audience?: string; offer?: string }) => {
449
  setBatchResults([]);
450
  setIsGenerating(true);
451
  setGenerationStartTime(Date.now());
 
 
 
 
 
 
452
  setBatchProgress(0);
453
  setCurrentBatchIndex(0);
454
  setBatchCount(data.count);
@@ -478,11 +488,7 @@ export default function GeneratePage() {
478
  }, progressInterval);
479
 
480
  try {
481
- const result = await generateBatch({
482
- ...data,
483
- target_audience: data.target_audience,
484
- offer: data.offer,
485
- });
486
  clearInterval(progressIntervalId);
487
  setBatchResults(result.ads);
488
  setCurrentBatchIndex(data.count - 1); // Set to last ad
@@ -510,9 +516,9 @@ export default function GeneratePage() {
510
 
511
  const handleExtensiveGenerate = async (data: {
512
  niche: Niche;
513
- custom_niche?: string;
514
- target_audience?: string;
515
- offer?: string;
516
  num_images: number;
517
  num_strategies: number;
518
  image_model?: string | null;
@@ -521,6 +527,21 @@ export default function GeneratePage() {
521
  setIsGenerating(true);
522
  setGenerationStartTime(Date.now());
523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  // Calculate estimated time based on strategies and images
525
  // Step 1 (Researcher): ~10-15 seconds
526
  // Step 2 (Retrieve knowledge): ~15-20 seconds (parallel)
@@ -638,7 +659,7 @@ export default function GeneratePage() {
638
  });
639
 
640
  // Generate ad using extensive - returns BatchResponse like batch flow
641
- const result = await generateExtensiveAd(data);
642
 
643
  // Clear progress interval
644
  if (progressInterval) {
@@ -807,7 +828,7 @@ export default function GeneratePage() {
807
 
808
  <div>
809
  <label className="block text-sm font-semibold text-gray-700 mb-2">
810
- Target Audience
811
  </label>
812
  <input
813
  type="text"
@@ -820,7 +841,7 @@ export default function GeneratePage() {
820
 
821
  <div>
822
  <label className="block text-sm font-semibold text-gray-700 mb-2">
823
- Offer
824
  </label>
825
  <input
826
  type="text"
 
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());
215
 
216
+ const formattedData = {
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
223
  if (data.num_images > 1) {
224
  setBatchCount(data.num_images);
 
235
  try {
236
  // Use batch generation for multiple ads with standard method
237
  const batchResponse = await generateBatch({
238
+ niche: formattedData.niche,
239
+ count: formattedData.num_images,
240
  images_per_ad: 1, // Each ad gets 1 image
241
+ image_model: formattedData.image_model,
242
  method: "standard", // Use standard method only
243
+ target_audience: formattedData.target_audience,
244
+ offer: formattedData.offer,
245
  });
246
  const results = batchResponse.ads;
247
 
 
295
 
296
  // Generate single ad with 1 image
297
  const result = await generateAd({
298
+ ...formattedData,
299
  num_images: 1,
 
 
300
  });
301
 
302
  clearInterval(progressInterval);
 
368
  custom_concept: useCustomConcept ? JSON.stringify(effectiveConcept) : null,
369
  num_images: 1,
370
  image_model: imageModel,
371
+ target_audience: targetAudience || null,
372
+ offer: offer || null,
373
  core_motivator: motivator,
374
  });
375
  })
 
414
  try {
415
  const motivator =
416
  selectedMotivators.length > 0 ? selectedMotivators[0] : undefined;
417
+ const result = await generateMatrixAd({
418
+ niche,
419
+ angle_key: useCustomAngle ? "custom" : effectiveAngle.key,
420
+ concept_key: useCustomConcept ? "custom" : effectiveConcept.key,
421
+ custom_angle: useCustomAngle ? JSON.stringify(effectiveAngle) : null,
422
+ custom_concept: useCustomConcept ? JSON.stringify(effectiveConcept) : null,
423
+ num_images: 1,
424
+ image_model: imageModel,
425
+ target_audience: targetAudience || null,
426
+ offer: offer || null,
427
+ core_motivator: motivator,
428
+ });
429
 
430
  setCurrentGeneration(result);
431
  setProgress({
 
449
  }
450
  };
451
 
452
+ const handleBatchGenerate = async (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null; target_audience?: string | null; offer?: string | null }) => {
453
  setBatchResults([]);
454
  setIsGenerating(true);
455
  setGenerationStartTime(Date.now());
456
+
457
+ const formattedData = {
458
+ ...data,
459
+ target_audience: data.target_audience || undefined,
460
+ offer: data.offer || undefined,
461
+ };
462
  setBatchProgress(0);
463
  setCurrentBatchIndex(0);
464
  setBatchCount(data.count);
 
488
  }, progressInterval);
489
 
490
  try {
491
+ const result = await generateBatch(formattedData);
 
 
 
 
492
  clearInterval(progressIntervalId);
493
  setBatchResults(result.ads);
494
  setCurrentBatchIndex(data.count - 1); // Set to last ad
 
516
 
517
  const handleExtensiveGenerate = async (data: {
518
  niche: Niche;
519
+ custom_niche?: string | null;
520
+ target_audience?: string | null;
521
+ offer?: string | null;
522
  num_images: number;
523
  num_strategies: number;
524
  image_model?: string | null;
 
527
  setIsGenerating(true);
528
  setGenerationStartTime(Date.now());
529
 
530
+ const formattedData: {
531
+ niche: Niche;
532
+ custom_niche?: string;
533
+ target_audience?: string;
534
+ offer?: string;
535
+ num_images: number;
536
+ num_strategies: number;
537
+ image_model?: string | null;
538
+ } = {
539
+ ...data,
540
+ custom_niche: data.custom_niche || undefined,
541
+ target_audience: data.target_audience || undefined,
542
+ offer: data.offer || undefined,
543
+ };
544
+
545
  // Calculate estimated time based on strategies and images
546
  // Step 1 (Researcher): ~10-15 seconds
547
  // Step 2 (Retrieve knowledge): ~15-20 seconds (parallel)
 
659
  });
660
 
661
  // Generate ad using extensive - returns BatchResponse like batch flow
662
+ const result = await generateExtensiveAd(formattedData);
663
 
664
  // Clear progress interval
665
  if (progressInterval) {
 
828
 
829
  <div>
830
  <label className="block text-sm font-semibold text-gray-700 mb-2">
831
+ Target Audience <span className="text-gray-400 font-normal">(Optional)</span>
832
  </label>
833
  <input
834
  type="text"
 
841
 
842
  <div>
843
  <label className="block text-sm font-semibold text-gray-700 mb-2">
844
+ Offer <span className="text-gray-400 font-normal">(Optional)</span>
845
  </label>
846
  <input
847
  type="text"
frontend/components/generation/BatchForm.tsx CHANGED
@@ -12,7 +12,7 @@ import { IMAGE_MODELS } from "@/lib/constants/models";
12
  import type { Niche } from "@/types/api";
13
 
14
  interface BatchFormProps {
15
- onSubmit: (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null; target_audience?: string; offer?: string }) => Promise<void>;
16
  isLoading: boolean;
17
  }
18
 
@@ -62,7 +62,7 @@ export const BatchForm: React.FC<BatchFormProps> = ({
62
 
63
  <div>
64
  <label className="block text-sm font-semibold text-gray-700 mb-2">
65
- Target Audience
66
  </label>
67
  <input
68
  type="text"
@@ -77,7 +77,7 @@ export const BatchForm: React.FC<BatchFormProps> = ({
77
 
78
  <div>
79
  <label className="block text-sm font-semibold text-gray-700 mb-2">
80
- Offer
81
  </label>
82
  <input
83
  type="text"
 
12
  import type { Niche } from "@/types/api";
13
 
14
  interface BatchFormProps {
15
+ onSubmit: (data: { niche: Niche; count: number; images_per_ad: number; image_model?: string | null; target_audience?: string | null; offer?: string | null }) => Promise<void>;
16
  isLoading: boolean;
17
  }
18
 
 
62
 
63
  <div>
64
  <label className="block text-sm font-semibold text-gray-700 mb-2">
65
+ Target Audience <span className="text-gray-400 font-normal">(Optional)</span>
66
  </label>
67
  <input
68
  type="text"
 
77
 
78
  <div>
79
  <label className="block text-sm font-semibold text-gray-700 mb-2">
80
+ Offer <span className="text-gray-400 font-normal">(Optional)</span>
81
  </label>
82
  <input
83
  type="text"
frontend/components/generation/ExtensiveForm.tsx CHANGED
@@ -11,9 +11,9 @@ import type { Niche } from "@/types/api";
11
 
12
  const extensiveSchema = z.object({
13
  niche: z.enum(["home_insurance", "glp1", "others"]),
14
- custom_niche: z.string().optional(),
15
- target_audience: z.string().optional(),
16
- offer: z.string().optional(),
17
  num_images: z.number().min(1).max(3),
18
  num_strategies: z.number().min(1).max(10),
19
  image_model: z.string().nullable().optional(),
@@ -35,9 +35,9 @@ type ExtensiveFormData = z.infer<typeof extensiveSchema>;
35
  interface ExtensiveFormProps {
36
  onSubmit: (data: {
37
  niche: Niche;
38
- custom_niche?: string;
39
- target_audience?: string;
40
- offer?: string;
41
  num_images: number;
42
  num_strategies: number;
43
  image_model?: string | null;
@@ -111,7 +111,7 @@ export const ExtensiveForm: React.FC<ExtensiveFormProps> = ({
111
 
112
  <div>
113
  <label className="block text-sm font-semibold text-gray-700 mb-2">
114
- Target Audience
115
  </label>
116
  <input
117
  type="text"
@@ -126,7 +126,7 @@ export const ExtensiveForm: React.FC<ExtensiveFormProps> = ({
126
 
127
  <div>
128
  <label className="block text-sm font-semibold text-gray-700 mb-2">
129
- Offer
130
  </label>
131
  <input
132
  type="text"
 
11
 
12
  const extensiveSchema = z.object({
13
  niche: z.enum(["home_insurance", "glp1", "others"]),
14
+ custom_niche: z.string().optional().nullable(),
15
+ target_audience: z.string().optional().nullable(),
16
+ offer: z.string().optional().nullable(),
17
  num_images: z.number().min(1).max(3),
18
  num_strategies: z.number().min(1).max(10),
19
  image_model: z.string().nullable().optional(),
 
35
  interface ExtensiveFormProps {
36
  onSubmit: (data: {
37
  niche: Niche;
38
+ custom_niche?: string | null;
39
+ target_audience?: string | null;
40
+ offer?: string | null;
41
  num_images: number;
42
  num_strategies: number;
43
  image_model?: string | null;
 
111
 
112
  <div>
113
  <label className="block text-sm font-semibold text-gray-700 mb-2">
114
+ Target Audience <span className="text-gray-400 font-normal">(Optional)</span>
115
  </label>
116
  <input
117
  type="text"
 
126
 
127
  <div>
128
  <label className="block text-sm font-semibold text-gray-700 mb-2">
129
+ Offer <span className="text-gray-400 font-normal">(Optional)</span>
130
  </label>
131
  <input
132
  type="text"
frontend/components/generation/GenerationForm.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import React from "react";
4
  import { useForm } from "react-hook-form";
5
  import { zodResolver } from "@hookform/resolvers/zod";
6
  import { generateAdSchema } from "@/lib/utils/validators";
@@ -10,21 +10,45 @@ import { Button } from "@/components/ui/Button";
10
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card";
11
  import { IMAGE_MODELS } from "@/lib/constants/models";
12
  import type { Niche } from "@/types/api";
 
 
13
 
14
  interface GenerationFormProps {
15
- onSubmit: (data: { niche: Niche; num_images: number; image_model?: string | null; target_audience?: string; offer?: string }) => Promise<void>;
 
 
 
 
 
 
 
 
16
  isLoading: boolean;
17
  }
18
 
 
 
 
 
 
 
 
 
19
  export const GenerationForm: React.FC<GenerationFormProps> = ({
20
  onSubmit,
21
  isLoading,
22
  }) => {
 
 
 
 
 
23
  const {
24
  register,
25
  handleSubmit,
26
  formState: { errors },
27
  watch,
 
28
  } = useForm({
29
  resolver: zodResolver(generateAdSchema),
30
  defaultValues: {
@@ -33,10 +57,54 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({
33
  image_model: null,
34
  target_audience: "",
35
  offer: "",
 
 
36
  },
37
  });
38
 
39
  const numImages = watch("num_images");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  return (
42
  <Card variant="glass">
@@ -60,7 +128,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({
60
 
61
  <div>
62
  <label className="block text-sm font-semibold text-gray-700 mb-2">
63
- Target Audience
64
  </label>
65
  <input
66
  type="text"
@@ -75,7 +143,7 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({
75
 
76
  <div>
77
  <label className="block text-sm font-semibold text-gray-700 mb-2">
78
- Offer
79
  </label>
80
  <input
81
  type="text"
@@ -88,6 +156,35 @@ export const GenerationForm: React.FC<GenerationFormProps> = ({
88
  )}
89
  </div>
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  <Select
92
  label="Image Model"
93
  options={IMAGE_MODELS.map(model => ({ value: model.value, label: model.label }))}
 
1
  "use client";
2
 
3
+ import React, { useState } from "react";
4
  import { useForm } from "react-hook-form";
5
  import { zodResolver } from "@hookform/resolvers/zod";
6
  import { generateAdSchema } from "@/lib/utils/validators";
 
10
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/Card";
11
  import { IMAGE_MODELS } from "@/lib/constants/models";
12
  import type { Niche } from "@/types/api";
13
+ import { Loader2, TrendingUp, Check } from "lucide-react";
14
+ import apiClient from "@/lib/api/client";
15
 
16
  interface GenerationFormProps {
17
+ onSubmit: (data: {
18
+ niche: Niche;
19
+ num_images: number;
20
+ image_model?: string | null;
21
+ target_audience?: string | null;
22
+ offer?: string | null;
23
+ use_trending?: boolean;
24
+ trending_context?: string | null;
25
+ }) => Promise<void>;
26
  isLoading: boolean;
27
  }
28
 
29
+ interface TrendItem {
30
+ title: string;
31
+ description: string;
32
+ url?: string;
33
+ keyword?: string;
34
+ relevance_score?: number;
35
+ }
36
+
37
  export const GenerationForm: React.FC<GenerationFormProps> = ({
38
  onSubmit,
39
  isLoading,
40
  }) => {
41
+ const [trends, setTrends] = useState<TrendItem[]>([]);
42
+ const [selectedTrend, setSelectedTrend] = useState<TrendItem | null>(null);
43
+ const [isFetchingTrends, setIsFetchingTrends] = useState(false);
44
+ const [trendsError, setTrendsError] = useState<string | null>(null);
45
+
46
  const {
47
  register,
48
  handleSubmit,
49
  formState: { errors },
50
  watch,
51
+ setValue,
52
  } = useForm({
53
  resolver: zodResolver(generateAdSchema),
54
  defaultValues: {
 
57
  image_model: null,
58
  target_audience: "",
59
  offer: "",
60
+ use_trending: false,
61
+ trending_context: "",
62
  },
63
  });
64
 
65
  const numImages = watch("num_images");
66
+ const currentNiche = watch("niche");
67
+ const useTrending = watch("use_trending");
68
+
69
+ // Fetch trends when toggle is enabled
70
+ const handleFetchTrends = async () => {
71
+ setIsFetchingTrends(true);
72
+ setTrendsError(null);
73
+ setTrends([]);
74
+ setSelectedTrend(null);
75
+
76
+ try {
77
+ const response = await apiClient.get(`/api/trends/${currentNiche}`);
78
+ const data = response.data;
79
+
80
+ if (data.trends && data.trends.length > 0) {
81
+ setTrends(data.trends);
82
+ } else {
83
+ setTrendsError("No relevant trends found for this niche");
84
+ }
85
+ } catch (error: any) {
86
+ setTrendsError(error.message || "Failed to fetch trends");
87
+ } finally {
88
+ setIsFetchingTrends(false);
89
+ }
90
+ };
91
+
92
+ // Handle trend selection
93
+ const handleSelectTrend = (trend: TrendItem) => {
94
+ setSelectedTrend(trend);
95
+ // Set the trending context with title and description
96
+ const trendContext = `${trend.title} - ${trend.description}`;
97
+ setValue("trending_context", trendContext);
98
+ };
99
+
100
+ // Reset trends when toggle is turned off
101
+ React.useEffect(() => {
102
+ if (!useTrending) {
103
+ setTrends([]);
104
+ setSelectedTrend(null);
105
+ setTrendsError(null);
106
+ }
107
+ }, [useTrending]);
108
 
109
  return (
110
  <Card variant="glass">
 
128
 
129
  <div>
130
  <label className="block text-sm font-semibold text-gray-700 mb-2">
131
+ Target Audience <span className="text-gray-400 font-normal">(Optional)</span>
132
  </label>
133
  <input
134
  type="text"
 
143
 
144
  <div>
145
  <label className="block text-sm font-semibold text-gray-700 mb-2">
146
+ Offer <span className="text-gray-400 font-normal">(Optional)</span>
147
  </label>
148
  <input
149
  type="text"
 
156
  )}
157
  </div>
158
 
159
+ {/* Trending Topics Section - COMING SOON */}
160
+ <div className="border-t border-gray-200 pt-4">
161
+ <div className="flex items-center justify-between mb-3">
162
+ <div className="opacity-50">
163
+ <label className="block text-sm font-semibold text-gray-700">
164
+ Use Trending Topics 🔥
165
+ </label>
166
+ <p className="text-xs text-gray-500 mt-1">
167
+ Incorporate current affairs and news for increased relevance
168
+ </p>
169
+ </div>
170
+ <div className="flex items-center gap-2">
171
+ <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">
172
+ Coming Soon
173
+ </span>
174
+ <label className="relative inline-flex items-center cursor-not-allowed opacity-50">
175
+ <input
176
+ type="checkbox"
177
+ className="sr-only peer"
178
+ disabled
179
+ {...register("use_trending")}
180
+ />
181
+ <div className="w-11 h-6 bg-gray-200 rounded-full"></div>
182
+ </label>
183
+ </div>
184
+ </div>
185
+ </div>
186
+
187
+
188
  <Select
189
  label="Image Model"
190
  options={IMAGE_MODELS.map(model => ({ value: model.value, label: model.label }))}
frontend/lib/api/endpoints.ts CHANGED
@@ -44,8 +44,10 @@ export const generateAd = async (params: {
44
  niche: Niche;
45
  num_images: number;
46
  image_model?: string | null;
47
- target_audience?: string;
48
- offer?: string;
 
 
49
  }): Promise<GenerateResponse> => {
50
  const response = await apiClient.post<GenerateResponse>("/generate", params);
51
  return response.data;
@@ -57,8 +59,8 @@ export const generateBatch = async (params: {
57
  images_per_ad: number;
58
  image_model?: string | null;
59
  method?: "standard" | "matrix" | null;
60
- target_audience?: string;
61
- offer?: string;
62
  }): Promise<BatchResponse> => {
63
  const response = await apiClient.post<BatchResponse>("/generate/batch", params);
64
  return response.data;
@@ -73,8 +75,8 @@ export const generateMatrixAd = async (params: {
73
  custom_concept?: string | null;
74
  num_images: number;
75
  image_model?: string | null;
76
- target_audience?: string;
77
- offer?: string;
78
  core_motivator?: string;
79
  }): Promise<MatrixGenerateResponse> => {
80
  const response = await apiClient.post<MatrixGenerateResponse>("/matrix/generate", params);
@@ -94,9 +96,9 @@ export const generateMotivators = async (
94
  // Extensive Endpoint
95
  export const generateExtensiveAd = async (params: {
96
  niche: Niche;
97
- custom_niche?: string;
98
- target_audience?: string;
99
- offer?: string;
100
  num_images: number;
101
  image_model?: string | null;
102
  num_strategies: number;
@@ -334,3 +336,13 @@ export const modifyCreative = async (params: {
334
  );
335
  return response.data;
336
  };
 
 
 
 
 
 
 
 
 
 
 
44
  niche: Niche;
45
  num_images: number;
46
  image_model?: string | null;
47
+ target_audience?: string | null;
48
+ offer?: string | null;
49
+ use_trending?: boolean;
50
+ trending_context?: string | null;
51
  }): Promise<GenerateResponse> => {
52
  const response = await apiClient.post<GenerateResponse>("/generate", params);
53
  return response.data;
 
59
  images_per_ad: number;
60
  image_model?: string | null;
61
  method?: "standard" | "matrix" | null;
62
+ target_audience?: string | null;
63
+ offer?: string | null;
64
  }): Promise<BatchResponse> => {
65
  const response = await apiClient.post<BatchResponse>("/generate/batch", params);
66
  return response.data;
 
75
  custom_concept?: string | null;
76
  num_images: number;
77
  image_model?: string | null;
78
+ target_audience?: string | null;
79
+ offer?: string | null;
80
  core_motivator?: string;
81
  }): Promise<MatrixGenerateResponse> => {
82
  const response = await apiClient.post<MatrixGenerateResponse>("/matrix/generate", params);
 
96
  // Extensive Endpoint
97
  export const generateExtensiveAd = async (params: {
98
  niche: Niche;
99
+ custom_niche?: string | null;
100
+ target_audience?: string | null;
101
+ offer?: string | null;
102
  num_images: number;
103
  image_model?: string | null;
104
  num_strategies: number;
 
336
  );
337
  return response.data;
338
  };
339
+
340
+ // Bulk Export Endpoint
341
+ export const exportBulkAds = async (adIds: string[]): Promise<Blob> => {
342
+ const response = await apiClient.post(
343
+ "/api/export/bulk",
344
+ { ad_ids: adIds },
345
+ { responseType: "blob" }
346
+ );
347
+ return response.data as Blob;
348
+ };
frontend/lib/utils/validators.ts CHANGED
@@ -4,8 +4,10 @@ export const generateAdSchema = z.object({
4
  niche: z.enum(["home_insurance", "glp1"]),
5
  num_images: z.number().min(1).max(10),
6
  image_model: z.string().optional().nullable(),
7
- target_audience: z.string().optional(),
8
- offer: z.string().optional(),
 
 
9
  });
10
 
11
  export const generateBatchSchema = z.object({
@@ -13,8 +15,8 @@ export const generateBatchSchema = z.object({
13
  count: z.number().min(1).max(20),
14
  images_per_ad: z.number().min(1).max(3),
15
  image_model: z.string().optional().nullable(),
16
- target_audience: z.string().optional(),
17
- offer: z.string().optional(),
18
  });
19
 
20
  export const generateMatrixSchema = z.object({
@@ -23,8 +25,8 @@ export const generateMatrixSchema = z.object({
23
  concept_key: z.string().optional().nullable(),
24
  num_images: z.number().min(1).max(5),
25
  image_model: z.string().optional().nullable(),
26
- target_audience: z.string().optional(),
27
- offer: z.string().optional(),
28
  });
29
 
30
  export const testingMatrixSchema = z.object({
 
4
  niche: z.enum(["home_insurance", "glp1"]),
5
  num_images: z.number().min(1).max(10),
6
  image_model: z.string().optional().nullable(),
7
+ target_audience: z.string().optional().nullable(),
8
+ offer: z.string().optional().nullable(),
9
+ use_trending: z.boolean().optional(),
10
+ trending_context: z.string().optional().nullable(),
11
  });
12
 
13
  export const generateBatchSchema = z.object({
 
15
  count: z.number().min(1).max(20),
16
  images_per_ad: z.number().min(1).max(3),
17
  image_model: z.string().optional().nullable(),
18
+ target_audience: z.string().optional().nullable(),
19
+ offer: z.string().optional().nullable(),
20
  });
21
 
22
  export const generateMatrixSchema = z.object({
 
25
  concept_key: z.string().optional().nullable(),
26
  num_images: z.number().min(1).max(5),
27
  image_model: z.string().optional().nullable(),
28
+ target_audience: z.string().optional().nullable(),
29
+ offer: z.string().optional().nullable(),
30
  });
31
 
32
  export const testingMatrixSchema = z.object({
frontend/types/api.ts CHANGED
@@ -414,8 +414,8 @@ export interface MotivatorGenerateRequest {
414
  niche: Niche;
415
  angle: { name: string; trigger: string; example?: string; [k: string]: unknown };
416
  concept: { name: string; structure: string; visual: string; [k: string]: unknown };
417
- target_audience?: string;
418
- offer?: string;
419
  count?: number;
420
  }
421
 
 
414
  niche: Niche;
415
  angle: { name: string; trigger: string; example?: string; [k: string]: unknown };
416
  concept: { name: string; structure: string; visual: string; [k: string]: unknown };
417
+ target_audience?: string | null;
418
+ offer?: string | null;
419
  count?: number;
420
  }
421
 
main.py CHANGED
@@ -5,7 +5,7 @@ Saves all ads to Neon PostgreSQL database with image URLs
5
  """
6
 
7
  from contextlib import asynccontextmanager
8
- from fastapi import FastAPI, HTTPException, Request, Response, Depends
9
  from fastapi.middleware.cors import CORSMiddleware
10
  from fastapi.staticfiles import StaticFiles
11
  from fastapi.responses import FileResponse, StreamingResponse, Response as FastAPIResponse
@@ -29,6 +29,8 @@ from services.image import image_service
29
  from services.auth import auth_service
30
  from services.auth_dependency import get_current_user
31
  from services.motivator import generate_motivators as motivator_generate
 
 
32
  from config import settings
33
 
34
  # Configure logging for API
@@ -129,6 +131,14 @@ class GenerateRequest(BaseModel):
129
  default=None,
130
  description="Optional offer to run (e.g., 'Don't overpay your insurance')"
131
  )
 
 
 
 
 
 
 
 
132
 
133
 
134
  class GenerateBatchRequest(BaseModel):
@@ -446,6 +456,8 @@ async def api_info():
446
  "POST /api/creative/analyze": "Analyze a creative image with AI vision (via URL)",
447
  "POST /api/creative/analyze/upload": "Analyze a creative image with AI vision (via file upload)",
448
  "POST /api/creative/modify": "Modify a creative with new angle/concept",
 
 
449
  "GET /health": "Health check",
450
  },
451
  "supported_niches": ["home_insurance", "glp1"],
@@ -487,6 +499,91 @@ async def health():
487
  return {"status": "ok"}
488
 
489
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  # =============================================================================
491
  # AUTHENTICATION ENDPOINTS
492
  # =============================================================================
@@ -559,9 +656,13 @@ async def generate(
559
  - Random visual styles and moods
560
  - Random seed for image generation
561
 
562
- Supported niches:
563
  - home_insurance: Fear, urgency, savings, authority, guilt strategies
564
  - glp1: Shame, transformation, FOMO, authority, simplicity strategies
 
 
 
 
565
  """
566
  try:
567
  result = await ad_generator.generate_ad(
@@ -569,6 +670,10 @@ async def generate(
569
  num_images=request.num_images,
570
  image_model=request.image_model,
571
  username=username, # Pass current user
 
 
 
 
572
  )
573
  return result
574
  except Exception as e:
@@ -599,6 +704,8 @@ async def generate_batch(
599
  image_model=request.image_model,
600
  username=username, # Pass current user
601
  method=request.method, # Pass method parameter
 
 
602
  )
603
  return {
604
  "count": len(results),
@@ -939,8 +1046,8 @@ async def correct_image(
939
 
940
  if update_success:
941
  api_logger.info(f"✓ Original ad updated with corrected image (ID: {request.image_id})")
942
- # Add the updated ad ID to the response
943
- if "corrected_image" not in response_data:
944
  response_data["corrected_image"] = {}
945
  response_data["corrected_image"]["ad_id"] = request.image_id
946
  else:
@@ -1471,6 +1578,8 @@ async def generate_with_matrix(
1471
  image_model=request.image_model,
1472
  username=username,
1473
  core_motivator=request.core_motivator,
 
 
1474
  )
1475
  return result
1476
  except Exception as e:
@@ -2392,6 +2501,82 @@ Return ONLY the improved {field_label} text, without any explanations or additio
2392
  raise HTTPException(status_code=500, detail=f"Failed to generate AI edit: {str(e)}")
2393
 
2394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2395
  # Frontend proxy - forward non-API requests to Next.js
2396
  # This must be LAST so it doesn't intercept API routes
2397
  @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
@@ -2403,8 +2588,8 @@ async def frontend_proxy(path: str, request: StarletteRequest):
2403
  # Exact API-only routes (never frontend)
2404
  # Note: /health has its own explicit route, so not listed here
2405
  api_only_routes = [
2406
- "auth/login", "api/correct", "api/download-image", "db/stats", "db/ads",
2407
- "strategies", "extensive/generate"
2408
  ]
2409
 
2410
  # Routes that are API for POST but frontend for GET
 
5
  """
6
 
7
  from contextlib import asynccontextmanager
8
+ from fastapi import FastAPI, HTTPException, Request, Response, Depends, BackgroundTasks
9
  from fastapi.middleware.cors import CORSMiddleware
10
  from fastapi.staticfiles import StaticFiles
11
  from fastapi.responses import FileResponse, StreamingResponse, Response as FastAPIResponse
 
29
  from services.auth import auth_service
30
  from services.auth_dependency import get_current_user
31
  from services.motivator import generate_motivators as motivator_generate
32
+ from services.trend_monitor import trend_monitor
33
+ from services.export_service import export_service
34
  from config import settings
35
 
36
  # Configure logging for API
 
131
  default=None,
132
  description="Optional offer to run (e.g., 'Don't overpay your insurance')"
133
  )
134
+ use_trending: bool = Field(
135
+ default=False,
136
+ description="Whether to incorporate current trending topics from Google News"
137
+ )
138
+ trending_context: Optional[str] = Field(
139
+ default=None,
140
+ description="Specific trending context to use (auto-fetched if not provided when use_trending=True)"
141
+ )
142
 
143
 
144
  class GenerateBatchRequest(BaseModel):
 
456
  "POST /api/creative/analyze": "Analyze a creative image with AI vision (via URL)",
457
  "POST /api/creative/analyze/upload": "Analyze a creative image with AI vision (via file upload)",
458
  "POST /api/creative/modify": "Modify a creative with new angle/concept",
459
+ "GET /api/trends/{niche}": "Get current trending topics from Google News",
460
+ "GET /api/trends/angles/{niche}": "Get auto-generated angles from trending topics",
461
  "GET /health": "Health check",
462
  },
463
  "supported_niches": ["home_insurance", "glp1"],
 
499
  return {"status": "ok"}
500
 
501
 
502
+ # =============================================================================
503
+ # TRENDING TOPICS ENDPOINTS
504
+ # =============================================================================
505
+
506
+ @app.get("/api/trends/{niche}")
507
+ async def get_trends(
508
+ niche: Literal["home_insurance", "glp1"],
509
+ username: str = Depends(get_current_user)
510
+ ):
511
+ """
512
+ Get current trending topics for a niche from Google News.
513
+
514
+ Requires authentication.
515
+
516
+ 🚧 COMING SOON - This feature is currently under development.
517
+
518
+ Returns top 5 most relevant news articles with context for ad generation.
519
+ Articles are scored by relevance, recency, and emotional triggers.
520
+
521
+ Results are cached for 1 hour to avoid rate limits.
522
+ """
523
+ # Feature temporarily disabled - coming soon
524
+ return {
525
+ "status": "coming_soon",
526
+ "message": "🔥 Trending Topics feature is coming soon! Stay tuned.",
527
+ "niche": niche,
528
+ "trends": [],
529
+ "count": 0,
530
+ "available_soon": True
531
+ }
532
+
533
+ # Original implementation (commented out for later)
534
+ # try:
535
+ # trends = await trend_monitor.fetch_trends(niche)
536
+ # return {
537
+ # "niche": niche,
538
+ # "trends": trends,
539
+ # "count": len(trends),
540
+ # "fetched_at": datetime.now().isoformat()
541
+ # }
542
+ # except Exception as e:
543
+ # raise HTTPException(status_code=500, detail=str(e))
544
+
545
+
546
+ @app.get("/api/trends/angles/{niche}")
547
+ async def get_trending_angles(
548
+ niche: Literal["home_insurance", "glp1"],
549
+ username: str = Depends(get_current_user)
550
+ ):
551
+ """
552
+ Get auto-generated angle suggestions based on current trends.
553
+
554
+ Requires authentication.
555
+
556
+ 🚧 COMING SOON - This feature is currently under development.
557
+
558
+ These trending angles can be used in matrix generation like regular angles.
559
+ Each angle is generated from a real news article with:
560
+ - Detected psychological trigger
561
+ - Relevance score
562
+ - Expiry date (7 days)
563
+ """
564
+ # Feature temporarily disabled - coming soon
565
+ return {
566
+ "status": "coming_soon",
567
+ "message": "🔥 Trending Topics feature is coming soon! Stay tuned.",
568
+ "niche": niche,
569
+ "trending_angles": [],
570
+ "count": 0,
571
+ "available_soon": True
572
+ }
573
+
574
+ # Original implementation (commented out for later)
575
+ # try:
576
+ # angles = await trend_monitor.get_trending_angles(niche)
577
+ # return {
578
+ # "niche": niche,
579
+ # "trending_angles": angles,
580
+ # "count": len(angles),
581
+ # "fetched_at": datetime.now().isoformat()
582
+ # }
583
+ # except Exception as e:
584
+ # raise HTTPException(status_code=500, detail=str(e))
585
+
586
+
587
  # =============================================================================
588
  # AUTHENTICATION ENDPOINTS
589
  # =============================================================================
 
656
  - Random visual styles and moods
657
  - Random seed for image generation
658
 
659
+ Supports niches:
660
  - home_insurance: Fear, urgency, savings, authority, guilt strategies
661
  - glp1: Shame, transformation, FOMO, authority, simplicity strategies
662
+
663
+ Trending Topics Integration:
664
+ - Set use_trending=True to incorporate current Google News trends
665
+ - Optionally provide trending_context, or it will be auto-fetched
666
  """
667
  try:
668
  result = await ad_generator.generate_ad(
 
670
  num_images=request.num_images,
671
  image_model=request.image_model,
672
  username=username, # Pass current user
673
+ target_audience=request.target_audience,
674
+ offer=request.offer,
675
+ use_trending=request.use_trending,
676
+ trending_context=request.trending_context,
677
  )
678
  return result
679
  except Exception as e:
 
704
  image_model=request.image_model,
705
  username=username, # Pass current user
706
  method=request.method, # Pass method parameter
707
+ target_audience=request.target_audience,
708
+ offer=request.offer,
709
  )
710
  return {
711
  "count": len(results),
 
1046
 
1047
  if update_success:
1048
  api_logger.info(f"✓ Original ad updated with corrected image (ID: {request.image_id})")
1049
+ # Add the updated ad ID to the response; ensure corrected_image is a dict
1050
+ if not response_data.get("corrected_image"):
1051
  response_data["corrected_image"] = {}
1052
  response_data["corrected_image"]["ad_id"] = request.image_id
1053
  else:
 
1578
  image_model=request.image_model,
1579
  username=username,
1580
  core_motivator=request.core_motivator,
1581
+ target_audience=request.target_audience,
1582
+ offer=request.offer,
1583
  )
1584
  return result
1585
  except Exception as e:
 
2501
  raise HTTPException(status_code=500, detail=f"Failed to generate AI edit: {str(e)}")
2502
 
2503
 
2504
+ # =============================================================================
2505
+ # BULK EXPORT ENDPOINTS
2506
+ # =============================================================================
2507
+
2508
+ class BulkExportRequest(BaseModel):
2509
+ """Request schema for bulk export."""
2510
+ ad_ids: List[str] = Field(
2511
+ description="List of ad IDs to export",
2512
+ min_items=1,
2513
+ max_items=50
2514
+ )
2515
+
2516
+
2517
+ class BulkExportResponse(BaseModel):
2518
+ """Response schema for bulk export."""
2519
+ status: str
2520
+ message: str
2521
+ filename: str
2522
+
2523
+
2524
+ @app.post("/api/export/bulk")
2525
+ async def export_bulk_ads(
2526
+ request: BulkExportRequest,
2527
+ background_tasks: BackgroundTasks,
2528
+ username: str = Depends(get_current_user)
2529
+ ):
2530
+ """
2531
+ Export multiple ad creatives as a ZIP package.
2532
+
2533
+ Requires authentication. Users can only export their own ads.
2534
+
2535
+ Creates a ZIP file containing:
2536
+ - /creatives/ folder with renamed images (nomenclature: {niche}_{concept}_{angle}_{date}_{version}.png)
2537
+ - ad_copy_data.xlsx with core fields (Headline, Title, Description, CTA, Psychological Angle, Image Filename, Image URL)
2538
+
2539
+ Maximum 50 ads per export.
2540
+ """
2541
+ try:
2542
+ # Validate number of ads
2543
+ if len(request.ad_ids) > 50:
2544
+ raise HTTPException(
2545
+ status_code=400,
2546
+ detail="Maximum 50 ads can be exported at once"
2547
+ )
2548
+
2549
+ # Fetch all ads and verify ownership
2550
+ ads = []
2551
+ for ad_id in request.ad_ids:
2552
+ ad = await db_service.get_ad_creative(ad_id, username=username)
2553
+ if not ad:
2554
+ raise HTTPException(
2555
+ status_code=404,
2556
+ detail=f"Ad '{ad_id}' not found or access denied"
2557
+ )
2558
+ ads.append(ad)
2559
+
2560
+ # Create export package
2561
+ api_logger.info(f"Creating export package for {len(ads)} ads (user: {username})")
2562
+ zip_path = await export_service.create_export_package(ads)
2563
+
2564
+ # Schedule cleanup after response is sent
2565
+ background_tasks.add_task(export_service.cleanup_zip, zip_path)
2566
+
2567
+ return FileResponse(
2568
+ zip_path,
2569
+ media_type="application/zip",
2570
+ filename=os.path.basename(zip_path)
2571
+ )
2572
+
2573
+ except HTTPException:
2574
+ raise
2575
+ except Exception as e:
2576
+ api_logger.error(f"Bulk export failed: {e}")
2577
+ raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
2578
+
2579
+
2580
  # Frontend proxy - forward non-API requests to Next.js
2581
  # This must be LAST so it doesn't intercept API routes
2582
  @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
 
2588
  # Exact API-only routes (never frontend)
2589
  # Note: /health has its own explicit route, so not listed here
2590
  api_only_routes = [
2591
+ "auth/login", "api/correct", "api/download-image", "api/export/bulk",
2592
+ "db/stats", "db/ads", "strategies", "extensive/generate"
2593
  ]
2594
 
2595
  # Routes that are API for POST but frontend for GET
requirements.txt CHANGED
@@ -9,6 +9,7 @@ aiofiles>=23.0.0
9
  Pillow>=10.0.0
10
  replicate>=0.25.0
11
  python-docx>=1.1.0
 
12
  motor
13
  boto3>=1.34.0
14
  bcrypt>=4.0.0
 
9
  Pillow>=10.0.0
10
  replicate>=0.25.0
11
  python-docx>=1.1.0
12
+ openpyxl>=3.1.0
13
  motor
14
  boto3>=1.34.0
15
  bcrypt>=4.0.0
services/export_service.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Export Service for Creative Breakthrough
3
+ Handles bulk export of ad creatives with images and Excel data
4
+ """
5
+
6
+ import os
7
+ import zipfile
8
+ import tempfile
9
+ import shutil
10
+ import re
11
+ from datetime import datetime
12
+ from typing import List, Dict, Any, Optional
13
+ import httpx
14
+ from openpyxl import Workbook
15
+ from openpyxl.styles import Font, Alignment
16
+ import logging
17
+
18
+ from config import settings
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ExportService:
24
+ """Service for exporting ad creatives in bulk."""
25
+
26
+ def __init__(self):
27
+ self.temp_dir = None
28
+
29
+ def sanitize_filename(self, text: str, max_length: int = 50) -> str:
30
+ """
31
+ Sanitize text for use in filename.
32
+ Remove special characters and limit length.
33
+ """
34
+ if not text:
35
+ return "unknown"
36
+
37
+ # Convert to lowercase
38
+ text = text.lower()
39
+
40
+ # Replace spaces and special chars with underscore
41
+ text = re.sub(r'[^a-z0-9]+', '_', text)
42
+
43
+ # Remove leading/trailing underscores
44
+ text = text.strip('_')
45
+
46
+ # Limit length
47
+ if len(text) > max_length:
48
+ text = text[:max_length]
49
+
50
+ # Remove trailing underscore if any
51
+ text = text.rstrip('_')
52
+
53
+ return text or "unknown"
54
+
55
+ def generate_image_filename(
56
+ self,
57
+ ad: Dict[str, Any],
58
+ version: int,
59
+ date_str: str
60
+ ) -> str:
61
+ """
62
+ Generate filename using nomenclature:
63
+ {niche}_{concept}_{angle}_{date}_{version}.png
64
+
65
+ Example: home_insurance_before_after_fear_20260130_001.png
66
+ """
67
+ # Get niche
68
+ niche = self.sanitize_filename(ad.get("niche", "standard"), max_length=20)
69
+
70
+ # Get concept (from concept_name or concept_key)
71
+ concept = self.sanitize_filename(
72
+ ad.get("concept_name") or ad.get("concept_key") or "standard",
73
+ max_length=20
74
+ )
75
+
76
+ # Get angle (from angle_name or angle_key or psychological_angle)
77
+ angle = self.sanitize_filename(
78
+ ad.get("angle_name") or
79
+ ad.get("angle_key") or
80
+ ad.get("psychological_angle") or
81
+ "standard",
82
+ max_length=20
83
+ )
84
+
85
+ # Format version with leading zeros
86
+ version_str = f"{version:03d}"
87
+
88
+ # Construct filename
89
+ filename = f"{niche}_{concept}_{angle}_{date_str}_{version_str}.png"
90
+
91
+ return filename
92
+
93
+ async def download_image(self, image_url: str) -> Optional[bytes]:
94
+ """Download image from URL and return bytes."""
95
+ try:
96
+ # Handle local file paths
97
+ if not image_url.startswith(("http://", "https://")):
98
+ local_path = os.path.join(settings.output_dir, image_url.lstrip("/images/"))
99
+ if os.path.exists(local_path):
100
+ with open(local_path, "rb") as f:
101
+ return f.read()
102
+ logger.warning(f"Local file not found: {local_path}")
103
+ return None
104
+
105
+ # Download from URL
106
+ async with httpx.AsyncClient(timeout=30.0) as client:
107
+ response = await client.get(image_url)
108
+ response.raise_for_status()
109
+ return response.content
110
+ except Exception as e:
111
+ logger.error(f"Failed to download image from {image_url}: {e}")
112
+ return None
113
+
114
+ async def download_and_rename_images(
115
+ self,
116
+ ads: List[Dict[str, Any]],
117
+ output_dir: str
118
+ ) -> Dict[str, str]:
119
+ """
120
+ Download all images and rename them according to nomenclature.
121
+ Returns mapping of ad_id -> new_filename.
122
+ """
123
+ filename_map = {}
124
+ date_str = datetime.now().strftime("%Y%m%d")
125
+
126
+ for idx, ad in enumerate(ads, start=1):
127
+ ad_id = ad.get("id")
128
+
129
+ # Get image URL (prefer r2_url, fallback to image_url)
130
+ image_url = ad.get("r2_url") or ad.get("image_url")
131
+
132
+ if not image_url:
133
+ logger.warning(f"No image URL for ad {ad_id}, skipping")
134
+ continue
135
+
136
+ # Generate new filename
137
+ new_filename = self.generate_image_filename(ad, idx, date_str)
138
+
139
+ # Download image
140
+ logger.info(f"Downloading image {idx}/{len(ads)}: {image_url}")
141
+ image_bytes = await self.download_image(image_url)
142
+
143
+ if not image_bytes:
144
+ logger.warning(f"Failed to download image for ad {ad_id}, skipping")
145
+ continue
146
+
147
+ # Save with new filename
148
+ output_path = os.path.join(output_dir, new_filename)
149
+ with open(output_path, "wb") as f:
150
+ f.write(image_bytes)
151
+
152
+ filename_map[ad_id] = new_filename
153
+ logger.info(f"Saved: {new_filename}")
154
+
155
+ return filename_map
156
+
157
+ def create_excel_sheet(
158
+ self,
159
+ ads: List[Dict[str, Any]],
160
+ filename_map: Dict[str, str],
161
+ output_path: str
162
+ ):
163
+ """
164
+ Create Excel sheet with ad copy data.
165
+ Columns: Image Filename, Headline, Title, Description, CTA, Psychological Angle
166
+ """
167
+ wb = Workbook()
168
+ ws = wb.active
169
+ ws.title = "Ad Copy Data"
170
+
171
+ # Define headers (Core fields as requested)
172
+ headers = [
173
+ "Image Filename",
174
+ "Image URL",
175
+ "Headline",
176
+ "Title",
177
+ "Description",
178
+ "CTA",
179
+ "Psychological Angle",
180
+ "Niche",
181
+ "Created Date"
182
+ ]
183
+
184
+ # Write headers with formatting
185
+ for col_idx, header in enumerate(headers, start=1):
186
+ cell = ws.cell(row=1, column=col_idx, value=header)
187
+ cell.font = Font(bold=True)
188
+ cell.alignment = Alignment(horizontal="center", vertical="center")
189
+
190
+ # Write data rows
191
+ for row_idx, ad in enumerate(ads, start=2):
192
+ ad_id = ad.get("id")
193
+
194
+ # Get filename from map
195
+ filename = filename_map.get(ad_id, "N/A")
196
+
197
+ # Get image URL (prefer r2_url, fallback to image_url)
198
+ image_url = ad.get("r2_url") or ad.get("image_url") or ""
199
+
200
+ # Extract data
201
+ row_data = [
202
+ filename,
203
+ image_url,
204
+ ad.get("headline", ""),
205
+ ad.get("title", ""),
206
+ ad.get("description", ""),
207
+ ad.get("cta", ""),
208
+ ad.get("psychological_angle", ""),
209
+ ad.get("niche", ""),
210
+ ad.get("created_at", "")[:10] if ad.get("created_at") else "" # Date only
211
+ ]
212
+
213
+ # Write row
214
+ for col_idx, value in enumerate(row_data, start=1):
215
+ ws.cell(row=row_idx, column=col_idx, value=value)
216
+
217
+ # Auto-adjust column widths
218
+ for column in ws.columns:
219
+ max_length = 0
220
+ column_letter = column[0].column_letter
221
+ for cell in column:
222
+ try:
223
+ if cell.value:
224
+ max_length = max(max_length, len(str(cell.value)))
225
+ except:
226
+ pass
227
+ adjusted_width = min(max_length + 2, 50) # Cap at 50 for readability
228
+ ws.column_dimensions[column_letter].width = adjusted_width
229
+
230
+ # Freeze first row
231
+ ws.freeze_panes = "A2"
232
+
233
+ # Save workbook
234
+ wb.save(output_path)
235
+ logger.info(f"Excel sheet created: {output_path}")
236
+
237
+ async def create_export_package(
238
+ self,
239
+ ads: List[Dict[str, Any]]
240
+ ) -> str:
241
+ """
242
+ Create a complete export package with images and Excel sheet.
243
+ Returns path to the ZIP file.
244
+ """
245
+ # Create temporary directory for export
246
+ self.temp_dir = tempfile.mkdtemp(prefix="export_")
247
+
248
+ try:
249
+ # Create subdirectories
250
+ creatives_dir = os.path.join(self.temp_dir, "creatives")
251
+ os.makedirs(creatives_dir, exist_ok=True)
252
+
253
+ # Download and rename images
254
+ logger.info(f"Downloading {len(ads)} images...")
255
+ filename_map = await self.download_and_rename_images(ads, creatives_dir)
256
+
257
+ if not filename_map:
258
+ raise Exception("No images were successfully downloaded")
259
+
260
+ # Create Excel sheet
261
+ excel_path = os.path.join(self.temp_dir, "ad_copy_data.xlsx")
262
+ logger.info("Creating Excel sheet...")
263
+ self.create_excel_sheet(ads, filename_map, excel_path)
264
+
265
+ # Create ZIP file
266
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
267
+ zip_filename = f"creatives_export_{timestamp}.zip"
268
+ zip_path = os.path.join(tempfile.gettempdir(), zip_filename)
269
+
270
+ logger.info(f"Creating ZIP file: {zip_filename}")
271
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
272
+ # Add all images
273
+ for filename in os.listdir(creatives_dir):
274
+ file_path = os.path.join(creatives_dir, filename)
275
+ zipf.write(file_path, os.path.join("creatives", filename))
276
+
277
+ # Add Excel file
278
+ zipf.write(excel_path, "ad_copy_data.xlsx")
279
+
280
+ logger.info(f"Export package created successfully: {zip_path}")
281
+ return zip_path
282
+
283
+ except Exception as e:
284
+ logger.error(f"Failed to create export package: {e}")
285
+ raise
286
+ finally:
287
+ # Cleanup temp directory (but keep ZIP file)
288
+ if self.temp_dir and os.path.exists(self.temp_dir):
289
+ try:
290
+ shutil.rmtree(self.temp_dir)
291
+ except Exception as e:
292
+ logger.warning(f"Failed to cleanup temp directory: {e}")
293
+
294
+ def cleanup_zip(self, zip_path: str):
295
+ """Clean up the ZIP file after it's been sent."""
296
+ try:
297
+ if os.path.exists(zip_path):
298
+ os.remove(zip_path)
299
+ logger.info(f"Cleaned up ZIP file: {zip_path}")
300
+ except Exception as e:
301
+ logger.warning(f"Failed to cleanup ZIP file: {e}")
302
+
303
+
304
+ # Global export service instance
305
+ export_service = ExportService()
services/generator.py CHANGED
@@ -48,6 +48,13 @@ except ImportError:
48
  third_flow_available = False
49
  print("Note: Extensive service not available.")
50
 
 
 
 
 
 
 
 
51
  # Data module imports
52
  from data import home_insurance, glp1
53
  from services.matrix import matrix_service
@@ -608,10 +615,14 @@ NICHE-SPECIFIC REQUIREMENTS (GLP-1 / WEIGHT LOSS):
608
  power_words: List[str] = None,
609
  angle: Dict[str, Any] = None,
610
  concept: Dict[str, Any] = None,
 
 
 
611
  ) -> str:
612
  """
613
  Build professional LLM prompt for ad copy generation.
614
  Uses angle × concept matrix approach for psychological targeting.
 
615
  """
616
  strategy_names = [s["name"] for s in strategies]
617
  strategy_descriptions = [f"- {s['name']}: {s['description']}" for s in strategies]
@@ -732,6 +743,27 @@ WITHOUT NUMBERS (use if no numbers section):
732
  - "Finally, Peace of Mind"
733
  - "Sleep Better Knowing You're Covered\""""
734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  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.
736
 
737
  === CONTEXT ===
@@ -743,6 +775,7 @@ FRAMEWORK VISUAL STYLE: {framework_data.get('visual_style', '')}
743
  FRAMEWORK HOOK EXAMPLES: {', '.join(framework_hooks[:3]) if framework_hooks else 'N/A'}
744
  CREATIVE DIRECTION: {creative_direction}
745
  CALL-TO-ACTION: {cta}
 
746
 
747
  === ANGLE × CONCEPT FRAMEWORK ===
748
  ANGLE: {angle.get('name') if angle else 'N/A'}
@@ -755,6 +788,10 @@ CONCEPT: {concept.get('name') if concept else 'N/A'}
755
  - Visual Guidance: {concept.get('visual') if concept else 'N/A'}
756
  - This concept defines HOW to show it visually
757
 
 
 
 
 
758
  === CONTAINER FORMAT (Native-Looking Ad) ===
759
  CONTAINER TYPE: {container['name']}
760
  DESCRIPTION: {container.get('description', '')}
@@ -1405,6 +1442,10 @@ CRITICAL REQUIREMENTS:
1405
  num_images: int = 1,
1406
  image_model: Optional[str] = None,
1407
  username: Optional[str] = None, # Username of the user generating the ad
 
 
 
 
1408
  ) -> Dict[str, Any]:
1409
  """
1410
  Generate a complete ad creative with copy and image.
@@ -1418,9 +1459,13 @@ CRITICAL REQUIREMENTS:
1418
  - Random camera angle, lighting, composition
1419
  - Random seed for image generation
1420
 
 
 
1421
  Args:
1422
  niche: Target niche (home_insurance or glp1)
1423
  num_images: Number of images to generate
 
 
1424
 
1425
  Returns:
1426
  Dict with ad copy, image path, and metadata
@@ -1477,6 +1522,42 @@ CRITICAL REQUIREMENTS:
1477
  angle = combination["angle"]
1478
  concept = combination["concept"]
1479
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1480
  # Generate ad copy via LLM with professional prompt
1481
  copy_prompt = self._build_copy_prompt(
1482
  niche=niche,
@@ -1492,6 +1573,9 @@ CRITICAL REQUIREMENTS:
1492
  power_words=power_words,
1493
  angle=angle,
1494
  concept=concept,
 
 
 
1495
  )
1496
 
1497
  ad_copy = await llm_service.generate_json(
@@ -1693,6 +1777,8 @@ CRITICAL REQUIREMENTS:
1693
  image_model: Optional[str] = None,
1694
  username: Optional[str] = None,
1695
  core_motivator: Optional[str] = None,
 
 
1696
  ) -> Dict[str, Any]:
1697
  """
1698
  Generate ad using angle × concept matrix approach.
@@ -1802,6 +1888,8 @@ CONCEPT: {concept.get('name', 'Custom Concept')}
1802
  concept=concept,
1803
  niche_data=niche_data,
1804
  core_motivator=core_motivator,
 
 
1805
  )
1806
 
1807
  # Generate ad copy
@@ -2477,6 +2565,8 @@ Return JSON:
2477
  concept: Dict[str, Any],
2478
  niche_data: Dict[str, Any],
2479
  core_motivator: Optional[str] = None,
 
 
2480
  ) -> str:
2481
  """Build ad copy prompt using angle + concept framework."""
2482
 
@@ -2527,6 +2617,10 @@ CONCEPT: {concept.get('name')}
2527
  - Visual Guidance: {concept.get('visual')}
2528
  - This concept defines HOW to show it visually
2529
 
 
 
 
 
2530
  === CONTEXT ===
2531
  NICHE: {niche.replace("_", " ").title()}
2532
  CTA: {cta}
@@ -2698,6 +2792,8 @@ If this image includes people or faces, they MUST look like real, original peopl
2698
  image_model: Optional[str] = None,
2699
  username: Optional[str] = None, # Username of the user generating the ads
2700
  method: Optional[str] = None, # "standard", "matrix", or None (mixed)
 
 
2701
  ) -> List[Dict[str, Any]]:
2702
  """
2703
  Generate multiple ad creatives - PARALLELIZED.
@@ -2737,6 +2833,8 @@ If this image includes people or faces, they MUST look like real, original peopl
2737
  num_images=images_per_ad,
2738
  image_model=image_model,
2739
  username=username, # Pass username
 
 
2740
  )
2741
  # Normalize matrix result to standard format for batch response
2742
  # Extract matrix info and convert metadata
@@ -2765,6 +2863,8 @@ If this image includes people or faces, they MUST look like real, original peopl
2765
  num_images=images_per_ad,
2766
  image_model=image_model,
2767
  username=username, # Pass username
 
 
2768
  )
2769
  return result
2770
  except Exception as e:
 
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
60
  from services.matrix import matrix_service
 
615
  power_words: List[str] = None,
616
  angle: Dict[str, Any] = None,
617
  concept: Dict[str, Any] = None,
618
+ target_audience: Optional[str] = None,
619
+ offer: Optional[str] = None,
620
+ trending_context: Optional[str] = None,
621
  ) -> str:
622
  """
623
  Build professional LLM prompt for ad copy generation.
624
  Uses angle × concept matrix approach for psychological targeting.
625
+ Can optionally incorporate trending topics for increased relevance.
626
  """
627
  strategy_names = [s["name"] for s in strategies]
628
  strategy_descriptions = [f"- {s['name']}: {s['description']}" for s in strategies]
 
743
  - "Finally, Peace of Mind"
744
  - "Sleep Better Knowing You're Covered\""""
745
 
746
+ # Build trending topics section if available
747
+ trending_section = ""
748
+ if trending_context:
749
+ trending_section = f"""
750
+ === TRENDING TOPICS CONTEXT (INCORPORATE THIS!) ===
751
+ Current Trend: {trending_context}
752
+
753
+ INSTRUCTIONS FOR USING TRENDING TOPICS:
754
+ - Subtly reference or tie the ad message to this trending topic
755
+ - Make the connection feel natural, not forced
756
+ - Use the trend to create urgency or relevance ("Everyone's talking about...")
757
+ - The trend should enhance the hook, not overshadow the core message
758
+ - Examples:
759
+ * "With [trend], now is the perfect time to..."
760
+ * "While everyone's focused on [trend], don't forget about..."
761
+ * "Just like [trend], your [product benefit]..."
762
+ * Reference the trend indirectly in the hook or primary text
763
+
764
+ NOTE: The trend adds timeliness and relevance. Use it strategically!
765
+ """
766
+
767
  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.
768
 
769
  === CONTEXT ===
 
775
  FRAMEWORK HOOK EXAMPLES: {', '.join(framework_hooks[:3]) if framework_hooks else 'N/A'}
776
  CREATIVE DIRECTION: {creative_direction}
777
  CALL-TO-ACTION: {cta}
778
+ {trending_section}
779
 
780
  === ANGLE × CONCEPT FRAMEWORK ===
781
  ANGLE: {angle.get('name') if angle else 'N/A'}
 
788
  - Visual Guidance: {concept.get('visual') if concept else 'N/A'}
789
  - This concept defines HOW to show it visually
790
 
791
+ {f'=== USER INPUTS ===' if target_audience or offer else ''}
792
+ {f'TARGET AUDIENCE: {target_audience}' if target_audience else ''}
793
+ {f'OFFER: {offer}' if offer else ''}
794
+
795
  === CONTAINER FORMAT (Native-Looking Ad) ===
796
  CONTAINER TYPE: {container['name']}
797
  DESCRIPTION: {container.get('description', '')}
 
1442
  num_images: int = 1,
1443
  image_model: Optional[str] = None,
1444
  username: Optional[str] = None, # Username of the user generating the ad
1445
+ target_audience: Optional[str] = None,
1446
+ offer: Optional[str] = None,
1447
+ use_trending: bool = False, # Whether to incorporate trending topics
1448
+ trending_context: Optional[str] = None, # Optional specific trending context
1449
  ) -> Dict[str, Any]:
1450
  """
1451
  Generate a complete ad creative with copy and image.
 
1459
  - Random camera angle, lighting, composition
1460
  - Random seed for image generation
1461
 
1462
+ Can optionally incorporate current trending topics from Google News.
1463
+
1464
  Args:
1465
  niche: Target niche (home_insurance or glp1)
1466
  num_images: Number of images to generate
1467
+ use_trending: Whether to incorporate current trending topics
1468
+ trending_context: Specific trending context (auto-fetched if not provided)
1469
 
1470
  Returns:
1471
  Dict with ad copy, image path, and metadata
 
1522
  angle = combination["angle"]
1523
  concept = combination["concept"]
1524
 
1525
+ # Fetch trending context if requested
1526
+ trending_info = None
1527
+ if use_trending and trend_monitor_available:
1528
+ try:
1529
+ if not trending_context:
1530
+ # Auto-fetch current trends
1531
+ print("📰 Fetching current trending topics...")
1532
+ trends_data = await asyncio.to_thread(
1533
+ trend_monitor.get_relevant_trends_for_niche,
1534
+ niche.replace("_", " ").title()
1535
+ )
1536
+ if trends_data and trends_data.get("relevant_trends"):
1537
+ # Use top trend for context
1538
+ top_trend = trends_data["relevant_trends"][0]
1539
+ trending_context = f"{top_trend['title']} - {top_trend['summary']}"
1540
+ trending_info = {
1541
+ "title": top_trend["title"],
1542
+ "summary": top_trend["summary"],
1543
+ "category": top_trend.get("category", "General"),
1544
+ "source": "Google News",
1545
+ }
1546
+ print(f"✓ Using trend: {top_trend['title']}")
1547
+ else:
1548
+ # User provided specific trending context
1549
+ trending_info = {
1550
+ "context": trending_context,
1551
+ "source": "User provided",
1552
+ }
1553
+ print(f"✓ Using user-provided trending context")
1554
+ except Exception as e:
1555
+ print(f"Warning: Failed to fetch trending topics: {e}")
1556
+ use_trending = False
1557
+ elif use_trending and not trend_monitor_available:
1558
+ print("Warning: Trending topics requested but trend monitor not available")
1559
+ use_trending = False
1560
+
1561
  # Generate ad copy via LLM with professional prompt
1562
  copy_prompt = self._build_copy_prompt(
1563
  niche=niche,
 
1573
  power_words=power_words,
1574
  angle=angle,
1575
  concept=concept,
1576
+ target_audience=target_audience,
1577
+ offer=offer,
1578
+ trending_context=trending_context if use_trending else None,
1579
  )
1580
 
1581
  ad_copy = await llm_service.generate_json(
 
1777
  image_model: Optional[str] = None,
1778
  username: Optional[str] = None,
1779
  core_motivator: Optional[str] = None,
1780
+ target_audience: Optional[str] = None,
1781
+ offer: Optional[str] = None,
1782
  ) -> Dict[str, Any]:
1783
  """
1784
  Generate ad using angle × concept matrix approach.
 
1888
  concept=concept,
1889
  niche_data=niche_data,
1890
  core_motivator=core_motivator,
1891
+ target_audience=target_audience,
1892
+ offer=offer,
1893
  )
1894
 
1895
  # Generate ad copy
 
2565
  concept: Dict[str, Any],
2566
  niche_data: Dict[str, Any],
2567
  core_motivator: Optional[str] = None,
2568
+ target_audience: Optional[str] = None,
2569
+ offer: Optional[str] = None,
2570
  ) -> str:
2571
  """Build ad copy prompt using angle + concept framework."""
2572
 
 
2617
  - Visual Guidance: {concept.get('visual')}
2618
  - This concept defines HOW to show it visually
2619
 
2620
+ {f'=== USER INPUTS ===' if target_audience or offer else ''}
2621
+ {f'TARGET AUDIENCE: {target_audience}' if target_audience else ''}
2622
+ {f'OFFER: {offer}' if offer else ''}
2623
+
2624
  === CONTEXT ===
2625
  NICHE: {niche.replace("_", " ").title()}
2626
  CTA: {cta}
 
2792
  image_model: Optional[str] = None,
2793
  username: Optional[str] = None, # Username of the user generating the ads
2794
  method: Optional[str] = None, # "standard", "matrix", or None (mixed)
2795
+ target_audience: Optional[str] = None,
2796
+ offer: Optional[str] = None,
2797
  ) -> List[Dict[str, Any]]:
2798
  """
2799
  Generate multiple ad creatives - PARALLELIZED.
 
2833
  num_images=images_per_ad,
2834
  image_model=image_model,
2835
  username=username, # Pass username
2836
+ target_audience=target_audience,
2837
+ offer=offer,
2838
  )
2839
  # Normalize matrix result to standard format for batch response
2840
  # Extract matrix info and convert metadata
 
2863
  num_images=images_per_ad,
2864
  image_model=image_model,
2865
  username=username, # Pass username
2866
+ target_audience=target_audience,
2867
+ offer=offer,
2868
  )
2869
  return result
2870
  except Exception as e:
services/motivator.py CHANGED
@@ -42,9 +42,9 @@ async def generate_motivators(
42
  concept_visual = concept.get("visual", "")
43
 
44
  extra = []
45
- if target_audience:
46
  extra.append(f"Target audience: {target_audience}")
47
- if offer:
48
  extra.append(f"Offer: {offer}")
49
  extra_block = "\n".join(extra) if extra else ""
50
  extra_segment = f"\n{extra_block}" if extra_block else ""
 
42
  concept_visual = concept.get("visual", "")
43
 
44
  extra = []
45
+ if target_audience and str(target_audience).strip():
46
  extra.append(f"Target audience: {target_audience}")
47
+ if offer and str(offer).strip():
48
  extra.append(f"Offer: {offer}")
49
  extra_block = "\n".join(extra) if extra else ""
50
  extra_segment = f"\n{extra_block}" if extra_block else ""
services/trend_monitor.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
24
+
25
+ # Simple in-memory cache
26
+ TREND_CACHE = {}
27
+ CACHE_DURATION = timedelta(hours=1) # Cache for 1 hour
28
+
29
+
30
+ class TrendMonitor:
31
+ """Monitor Google News for trending topics relevant to ad generation"""
32
+
33
+ def __init__(self, language: str = "en", country: str = "US"):
34
+ self.google_news = GNews(language=language, country=country)
35
+ # Set period to last 7 days for freshness
36
+ self.google_news.period = '7d'
37
+ self.google_news.max_results = 10
38
+
39
+ async def fetch_trends(self, niche: str) -> List[Dict]:
40
+ """
41
+ Fetch trending news for a specific niche with caching
42
+
43
+ Args:
44
+ niche: Target niche (home_insurance or glp1)
45
+
46
+ Returns:
47
+ List of trend dicts with title, description, date, url, relevance_score
48
+ """
49
+ cache_key = f"trends_{niche}"
50
+
51
+ # Check cache
52
+ if cache_key in TREND_CACHE:
53
+ cached_data, cached_time = TREND_CACHE[cache_key]
54
+ if datetime.now() - cached_time < CACHE_DURATION:
55
+ print(f"✓ Using cached trends for {niche}")
56
+ return cached_data
57
+
58
+ # Fetch fresh data
59
+ print(f"🔍 Fetching fresh trends for {niche}...")
60
+ trends = await self._fetch_trends_uncached(niche)
61
+
62
+ # Update cache
63
+ TREND_CACHE[cache_key] = (trends, datetime.now())
64
+
65
+ return trends
66
+
67
+ async def _fetch_trends_uncached(self, niche: str) -> List[Dict]:
68
+ """
69
+ Fetch trending news without caching
70
+
71
+ Args:
72
+ niche: Target niche
73
+
74
+ Returns:
75
+ List of scored and ranked articles
76
+ """
77
+ if niche not in NICHE_KEYWORDS:
78
+ raise ValueError(f"Unsupported niche: {niche}. Supported: {list(NICHE_KEYWORDS.keys())}")
79
+
80
+ keywords = NICHE_KEYWORDS[niche]
81
+ all_articles = []
82
+
83
+ # Fetch articles for each keyword (limit to avoid rate limits)
84
+ for keyword in keywords[:3]: # Top 3 keywords
85
+ try:
86
+ # Run synchronous GNews call in thread pool
87
+ loop = asyncio.get_event_loop()
88
+ articles = await loop.run_in_executor(
89
+ None,
90
+ lambda: self.google_news.get_news(keyword)
91
+ )
92
+
93
+ for article in articles:
94
+ # Add metadata
95
+ article['keyword'] = keyword
96
+ article['niche'] = niche
97
+ all_articles.append(article)
98
+
99
+ except Exception as e:
100
+ print(f"⚠️ Error fetching news for '{keyword}': {e}")
101
+ continue
102
+
103
+ if not all_articles:
104
+ print(f"⚠️ No articles found for {niche}")
105
+ return []
106
+
107
+ # Score and rank by relevance
108
+ scored_articles = self._score_relevance(all_articles, niche)
109
+
110
+ print(f"✓ Found {len(scored_articles)} articles for {niche}")
111
+
112
+ # Return top 5 most relevant
113
+ return scored_articles[:5]
114
+
115
+ def _score_relevance(self, articles: List[Dict], niche: str) -> List[Dict]:
116
+ """
117
+ Score articles by relevance to niche
118
+
119
+ Args:
120
+ articles: List of article dicts
121
+ niche: Target niche
122
+
123
+ Returns:
124
+ Sorted list with relevance_score added
125
+ """
126
+ keywords = NICHE_KEYWORDS[niche]
127
+
128
+ for article in articles:
129
+ score = 0
130
+ text = f"{article.get('title', '')} {article.get('description', '')}".lower()
131
+
132
+ # Keyword matching (more matches = higher score)
133
+ for keyword in keywords:
134
+ if keyword.lower() in text:
135
+ score += 2
136
+
137
+ # Recency bonus (newer = better)
138
+ pub_date = article.get('published date')
139
+ if pub_date:
140
+ try:
141
+ # Handle datetime object
142
+ if isinstance(pub_date, datetime):
143
+ days_old = (datetime.now() - pub_date).days
144
+ else:
145
+ # Try parsing RFC 2822 format first (from RSS feeds)
146
+ from email.utils import parsedate_to_datetime
147
+ try:
148
+ pub_date_obj = parsedate_to_datetime(str(pub_date))
149
+ except:
150
+ # Fallback to ISO format
151
+ pub_date_obj = datetime.fromisoformat(str(pub_date))
152
+ days_old = (datetime.now() - pub_date_obj).days
153
+
154
+ if days_old <= 1:
155
+ score += 5 # Hot news
156
+ elif days_old <= 3:
157
+ score += 3
158
+ elif days_old <= 7:
159
+ score += 1
160
+ except Exception as e:
161
+ # Silently skip date parsing errors
162
+ pass
163
+
164
+ # Emotion triggers (fear, urgency, transformation)
165
+ emotion_words = [
166
+ 'crisis', 'warning', 'breakthrough', 'new', 'record',
167
+ 'shortage', 'surge', 'dramatic', 'shocking', 'urgent',
168
+ 'breaking', 'exclusive', 'major', 'critical'
169
+ ]
170
+ for word in emotion_words:
171
+ if word in text:
172
+ score += 1
173
+
174
+ article['relevance_score'] = score
175
+
176
+ # Sort by score descending
177
+ return sorted(articles, key=lambda x: x.get('relevance_score', 0), reverse=True)
178
+
179
+ def extract_trend_context(self, article: Dict) -> str:
180
+ """
181
+ Extract a concise trend context for prompt injection
182
+
183
+ Args:
184
+ article: Article dict
185
+
186
+ Returns:
187
+ Concise context string
188
+ """
189
+ title = article.get('title', '')
190
+ description = article.get('description', '')
191
+
192
+ # Create a concise context string
193
+ context = f"{title}"
194
+ if description and len(description) < 150:
195
+ context += f" - {description}"
196
+
197
+ return context.strip()
198
+
199
+ async def get_trending_angles(self, niche: str) -> List[Dict]:
200
+ """
201
+ Generate angle suggestions based on current trends
202
+
203
+ Args:
204
+ niche: Target niche
205
+
206
+ Returns:
207
+ List of angle dicts compatible with angle × concept system
208
+ """
209
+ trends = await self.fetch_trends(niche)
210
+
211
+ angles = []
212
+ for trend in trends[:3]: # Top 3 trends
213
+ title = trend.get('title', '')
214
+ context = self.extract_trend_context(trend)
215
+
216
+ # Analyze trend for psychological trigger
217
+ trigger = self._detect_trigger(title, trend.get('description', ''))
218
+
219
+ angle = {
220
+ "key": f"trend_{abs(hash(title)) % 10000}",
221
+ "name": f"Trending: {title[:40]}...",
222
+ "trigger": trigger,
223
+ "example": context[:100],
224
+ "category": "Trending",
225
+ "source": "google_news",
226
+ "url": trend.get('url'),
227
+ "expires": (datetime.now() + timedelta(days=7)).isoformat(),
228
+ "relevance_score": trend.get('relevance_score', 0)
229
+ }
230
+ angles.append(angle)
231
+
232
+ return angles
233
+
234
+ def _detect_trigger(self, title: str, description: str) -> str:
235
+ """
236
+ Detect psychological trigger from news content
237
+
238
+ Args:
239
+ title: Article title
240
+ description: Article description
241
+
242
+ Returns:
243
+ Trigger name (Fear, Hope, FOMO, etc.)
244
+ """
245
+ text = f"{title} {description}".lower()
246
+
247
+ # Trigger detection rules (ordered by priority)
248
+ if any(word in text for word in ['crisis', 'warning', 'danger', 'risk', 'threat', 'disaster']):
249
+ return "Fear"
250
+ elif any(word in text for word in ['shortage', 'limited', 'running out', 'exclusive', 'sold out']):
251
+ return "FOMO"
252
+ elif any(word in text for word in ['breakthrough', 'solution', 'cure', 'relief', 'success']):
253
+ return "Hope"
254
+ elif any(word in text for word in ['save', 'discount', 'cheaper', 'affordable', 'deal']):
255
+ return "Greed"
256
+ elif any(word in text for word in ['new', 'innovation', 'discover', 'reveal', 'secret']):
257
+ return "Curiosity"
258
+ elif any(word in text for word in ['urgent', 'now', 'immediate', 'breaking']):
259
+ return "Urgency"
260
+ else:
261
+ return "Emotion"
262
+
263
+ def clear_cache(self):
264
+ """Clear the trend cache (useful for testing)"""
265
+ global TREND_CACHE
266
+ TREND_CACHE = {}
267
+ print("✓ Trend cache cleared")
268
+
269
+
270
+ # Global instance
271
+ trend_monitor = TrendMonitor()