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 +18 -18
- frontend/app/gallery/page.tsx +43 -1
- frontend/app/generate/page.tsx +56 -35
- frontend/components/generation/BatchForm.tsx +3 -3
- frontend/components/generation/ExtensiveForm.tsx +8 -8
- frontend/components/generation/GenerationForm.tsx +101 -4
- frontend/lib/api/endpoints.ts +21 -9
- frontend/lib/utils/validators.ts +8 -6
- frontend/types/api.ts +2 -2
- main.py +191 -6
- requirements.txt +1 -0
- services/export_service.py +305 -0
- services/generator.py +100 -0
- services/motivator.py +2 -2
- services/trend_monitor.py +271 -0
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 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 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:
|
| 233 |
-
count:
|
| 234 |
images_per_ad: 1, // Each ad gets 1 image
|
| 235 |
-
image_model:
|
| 236 |
method: "standard", // Use standard method only
|
| 237 |
-
target_audience:
|
| 238 |
-
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 |
-
...
|
| 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 ||
|
| 368 |
-
offer: offer ||
|
| 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 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 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(
|
| 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: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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"
|
| 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", "
|
| 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()
|