Commit ·
8cab861
1
Parent(s): 8ffe335
- frontend/lib/api/endpoints.ts +33 -4
- main.py +111 -47
frontend/lib/api/endpoints.ts
CHANGED
|
@@ -93,7 +93,20 @@ export const generateMotivators = async (
|
|
| 93 |
return response.data;
|
| 94 |
};
|
| 95 |
|
| 96 |
-
// Extensive
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
export const generateExtensiveAd = async (params: {
|
| 98 |
niche: Niche;
|
| 99 |
custom_niche?: string | null;
|
|
@@ -103,7 +116,6 @@ export const generateExtensiveAd = async (params: {
|
|
| 103 |
image_model?: string | null;
|
| 104 |
num_strategies: number;
|
| 105 |
}): Promise<BatchResponse> => {
|
| 106 |
-
// Ensure required parameters are always sent
|
| 107 |
const requestParams = {
|
| 108 |
niche: params.niche,
|
| 109 |
num_images: params.num_images || 1,
|
|
@@ -113,8 +125,25 @@ export const generateExtensiveAd = async (params: {
|
|
| 113 |
...(params.offer && { offer: params.offer }),
|
| 114 |
...(params.image_model && { image_model: params.image_model }),
|
| 115 |
};
|
| 116 |
-
const response = await apiClient.post<
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
};
|
| 119 |
|
| 120 |
export const generateTestingMatrix = async (params: {
|
|
|
|
| 93 |
return response.data;
|
| 94 |
};
|
| 95 |
|
| 96 |
+
// Extensive Endpoints (async job pattern to avoid connection timeout on HF Spaces)
|
| 97 |
+
const EXTENSIVE_POLL_INTERVAL_MS = 5500;
|
| 98 |
+
const EXTENSIVE_POLL_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes
|
| 99 |
+
|
| 100 |
+
export const getExtensiveJobStatus = async (jobId: string): Promise<{ job_id: string; status: "running" | "completed" | "failed"; error?: string }> => {
|
| 101 |
+
const response = await apiClient.get(`/extensive/status/${jobId}`);
|
| 102 |
+
return response.data;
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
export const getExtensiveJobResult = async (jobId: string): Promise<BatchResponse> => {
|
| 106 |
+
const response = await apiClient.get<BatchResponse>(`/extensive/result/${jobId}`);
|
| 107 |
+
return response.data;
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
export const generateExtensiveAd = async (params: {
|
| 111 |
niche: Niche;
|
| 112 |
custom_niche?: string | null;
|
|
|
|
| 116 |
image_model?: string | null;
|
| 117 |
num_strategies: number;
|
| 118 |
}): Promise<BatchResponse> => {
|
|
|
|
| 119 |
const requestParams = {
|
| 120 |
niche: params.niche,
|
| 121 |
num_images: params.num_images || 1,
|
|
|
|
| 125 |
...(params.offer && { offer: params.offer }),
|
| 126 |
...(params.image_model && { image_model: params.image_model }),
|
| 127 |
};
|
| 128 |
+
const response = await apiClient.post<{ job_id: string; message?: string }>("/extensive/generate", requestParams);
|
| 129 |
+
const jobId = response.data?.job_id;
|
| 130 |
+
if (!jobId) {
|
| 131 |
+
throw new Error("Server did not return a job ID");
|
| 132 |
+
}
|
| 133 |
+
const deadline = Date.now() + EXTENSIVE_POLL_TIMEOUT_MS;
|
| 134 |
+
for (;;) {
|
| 135 |
+
const status = await getExtensiveJobStatus(jobId);
|
| 136 |
+
if (status.status === "completed") {
|
| 137 |
+
return getExtensiveJobResult(jobId);
|
| 138 |
+
}
|
| 139 |
+
if (status.status === "failed") {
|
| 140 |
+
throw new Error(status.error || "Extensive generation failed");
|
| 141 |
+
}
|
| 142 |
+
if (Date.now() >= deadline) {
|
| 143 |
+
throw new Error("Extensive generation timed out. Try fewer strategies or images.");
|
| 144 |
+
}
|
| 145 |
+
await new Promise((r) => setTimeout(r, EXTENSIVE_POLL_INTERVAL_MS));
|
| 146 |
+
}
|
| 147 |
};
|
| 148 |
|
| 149 |
export const generateTestingMatrix = async (params: {
|
main.py
CHANGED
|
@@ -12,6 +12,7 @@ from fastapi.responses import FileResponse, StreamingResponse, Response as FastA
|
|
| 12 |
from pydantic import BaseModel, Field
|
| 13 |
from typing import Optional, List, Literal, Any, Dict
|
| 14 |
from datetime import datetime
|
|
|
|
| 15 |
import os
|
| 16 |
import logging
|
| 17 |
import time
|
|
@@ -1810,9 +1811,12 @@ async def motivator_generate_endpoint(
|
|
| 1810 |
|
| 1811 |
|
| 1812 |
# =============================================================================
|
| 1813 |
-
# EXTENSIVE ENDPOINTS
|
| 1814 |
# =============================================================================
|
| 1815 |
|
|
|
|
|
|
|
|
|
|
| 1816 |
class ExtensiveGenerateRequest(BaseModel):
|
| 1817 |
"""Request for extensive generation."""
|
| 1818 |
niche: str = Field(
|
|
@@ -1848,58 +1852,118 @@ class ExtensiveGenerateRequest(BaseModel):
|
|
| 1848 |
)
|
| 1849 |
|
| 1850 |
|
| 1851 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1852 |
async def generate_extensive(
|
| 1853 |
request: ExtensiveGenerateRequest,
|
| 1854 |
username: str = Depends(get_current_user)
|
| 1855 |
):
|
| 1856 |
"""
|
| 1857 |
-
|
| 1858 |
-
|
| 1859 |
-
|
| 1860 |
-
|
| 1861 |
-
This flow:
|
| 1862 |
-
1. Researches psychology triggers, angles, and concepts
|
| 1863 |
-
2. Retrieves marketing book knowledge and old ads data
|
| 1864 |
-
3. Creates creative strategies
|
| 1865 |
-
4. Generates image prompts and ad copy in parallel
|
| 1866 |
-
5. Generates images for each strategy
|
| 1867 |
-
|
| 1868 |
-
Returns all generated ads from all strategies (like batch generation).
|
| 1869 |
-
|
| 1870 |
-
Supports custom niches via the 'others' option - when niche is 'others',
|
| 1871 |
-
custom_niche field must be provided with the custom niche name.
|
| 1872 |
"""
|
| 1873 |
-
|
| 1874 |
-
|
| 1875 |
-
|
| 1876 |
-
|
| 1877 |
-
|
| 1878 |
-
|
| 1879 |
-
|
| 1880 |
-
|
| 1881 |
-
|
| 1882 |
-
|
| 1883 |
-
|
| 1884 |
-
|
| 1885 |
-
|
| 1886 |
-
|
| 1887 |
-
|
| 1888 |
-
|
| 1889 |
-
|
| 1890 |
-
|
| 1891 |
-
|
| 1892 |
-
|
| 1893 |
-
|
| 1894 |
-
|
| 1895 |
-
|
| 1896 |
-
|
| 1897 |
-
|
|
|
|
|
|
|
|
|
|
| 1898 |
)
|
| 1899 |
-
|
| 1900 |
-
|
| 1901 |
-
|
| 1902 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1903 |
|
| 1904 |
|
| 1905 |
# =============================================================================
|
|
@@ -2595,7 +2659,7 @@ async def frontend_proxy(path: str, request: StarletteRequest):
|
|
| 2595 |
# Note: /health has its own explicit route, so not listed here
|
| 2596 |
api_only_routes = [
|
| 2597 |
"auth/login", "api/correct", "api/download-image", "api/export/bulk",
|
| 2598 |
-
"db/stats", "db/ads", "strategies", "extensive/generate"
|
| 2599 |
]
|
| 2600 |
|
| 2601 |
# Routes that are API for POST but frontend for GET
|
|
|
|
| 12 |
from pydantic import BaseModel, Field
|
| 13 |
from typing import Optional, List, Literal, Any, Dict
|
| 14 |
from datetime import datetime
|
| 15 |
+
import asyncio
|
| 16 |
import os
|
| 17 |
import logging
|
| 18 |
import time
|
|
|
|
| 1811 |
|
| 1812 |
|
| 1813 |
# =============================================================================
|
| 1814 |
+
# EXTENSIVE ENDPOINTS (async job pattern to avoid connection timeout on HF Spaces)
|
| 1815 |
# =============================================================================
|
| 1816 |
|
| 1817 |
+
# In-memory job store: job_id -> { status, result?, error?, username }
|
| 1818 |
+
_extensive_jobs: Dict[str, Dict[str, Any]] = {}
|
| 1819 |
+
|
| 1820 |
class ExtensiveGenerateRequest(BaseModel):
|
| 1821 |
"""Request for extensive generation."""
|
| 1822 |
niche: str = Field(
|
|
|
|
| 1852 |
)
|
| 1853 |
|
| 1854 |
|
| 1855 |
+
class ExtensiveJobResponse(BaseModel):
|
| 1856 |
+
"""Response when extensive generation is started (202 Accepted)."""
|
| 1857 |
+
job_id: str
|
| 1858 |
+
message: str = "Extensive generation started. Poll /extensive/status/{job_id} for progress."
|
| 1859 |
+
|
| 1860 |
+
|
| 1861 |
+
async def _run_extensive_job_async(
|
| 1862 |
+
job_id: str,
|
| 1863 |
+
username: str,
|
| 1864 |
+
effective_niche: str,
|
| 1865 |
+
target_audience: Optional[str],
|
| 1866 |
+
offer: Optional[str],
|
| 1867 |
+
num_images: int,
|
| 1868 |
+
image_model: Optional[str],
|
| 1869 |
+
num_strategies: int,
|
| 1870 |
+
):
|
| 1871 |
+
"""Run extensive generation on the main event loop so DB and other async code use the same loop."""
|
| 1872 |
+
try:
|
| 1873 |
+
results = await ad_generator.generate_ad_extensive(
|
| 1874 |
+
niche=effective_niche,
|
| 1875 |
+
target_audience=target_audience,
|
| 1876 |
+
offer=offer,
|
| 1877 |
+
num_images=num_images,
|
| 1878 |
+
image_model=image_model,
|
| 1879 |
+
num_strategies=num_strategies,
|
| 1880 |
+
username=username,
|
| 1881 |
+
)
|
| 1882 |
+
_extensive_jobs[job_id]["status"] = "completed"
|
| 1883 |
+
_extensive_jobs[job_id]["result"] = BatchResponse(count=len(results), ads=results)
|
| 1884 |
+
except Exception as e:
|
| 1885 |
+
api_logger.exception("Extensive job %s failed", job_id)
|
| 1886 |
+
_extensive_jobs[job_id]["status"] = "failed"
|
| 1887 |
+
_extensive_jobs[job_id]["error"] = str(e)
|
| 1888 |
+
|
| 1889 |
+
|
| 1890 |
+
@app.post("/extensive/generate", status_code=202)
|
| 1891 |
async def generate_extensive(
|
| 1892 |
request: ExtensiveGenerateRequest,
|
| 1893 |
username: str = Depends(get_current_user)
|
| 1894 |
):
|
| 1895 |
"""
|
| 1896 |
+
Start extensive ad generation (researcher → creative director → designer → copywriter).
|
| 1897 |
+
Returns 202 with job_id. Poll GET /extensive/status/{job_id} then GET /extensive/result/{job_id}.
|
| 1898 |
+
Runs on the main event loop so DB (MongoDB) and other async code stay on the same loop.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1899 |
"""
|
| 1900 |
+
if request.niche == "others":
|
| 1901 |
+
if not request.custom_niche or not request.custom_niche.strip():
|
| 1902 |
+
raise HTTPException(
|
| 1903 |
+
status_code=400,
|
| 1904 |
+
detail="custom_niche is required when niche is 'others'"
|
| 1905 |
+
)
|
| 1906 |
+
effective_niche = request.custom_niche.strip()
|
| 1907 |
+
else:
|
| 1908 |
+
effective_niche = request.niche
|
| 1909 |
+
|
| 1910 |
+
job_id = str(uuid.uuid4())
|
| 1911 |
+
_extensive_jobs[job_id] = {
|
| 1912 |
+
"status": "running",
|
| 1913 |
+
"result": None,
|
| 1914 |
+
"error": None,
|
| 1915 |
+
"username": username,
|
| 1916 |
+
}
|
| 1917 |
+
|
| 1918 |
+
asyncio.create_task(
|
| 1919 |
+
_run_extensive_job_async(
|
| 1920 |
+
job_id,
|
| 1921 |
+
username,
|
| 1922 |
+
effective_niche,
|
| 1923 |
+
request.target_audience,
|
| 1924 |
+
request.offer,
|
| 1925 |
+
request.num_images,
|
| 1926 |
+
request.image_model,
|
| 1927 |
+
request.num_strategies,
|
| 1928 |
)
|
| 1929 |
+
)
|
| 1930 |
+
return ExtensiveJobResponse(job_id=job_id)
|
| 1931 |
+
|
| 1932 |
+
|
| 1933 |
+
@app.get("/extensive/status/{job_id}")
|
| 1934 |
+
async def extensive_job_status(
|
| 1935 |
+
job_id: str,
|
| 1936 |
+
username: str = Depends(get_current_user)
|
| 1937 |
+
):
|
| 1938 |
+
"""Get status of an extensive generation job."""
|
| 1939 |
+
if job_id not in _extensive_jobs:
|
| 1940 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 1941 |
+
job = _extensive_jobs[job_id]
|
| 1942 |
+
if job["username"] != username:
|
| 1943 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 1944 |
+
return {
|
| 1945 |
+
"job_id": job_id,
|
| 1946 |
+
"status": job["status"],
|
| 1947 |
+
"error": job.get("error") if job["status"] == "failed" else None,
|
| 1948 |
+
}
|
| 1949 |
+
|
| 1950 |
+
|
| 1951 |
+
@app.get("/extensive/result/{job_id}", response_model=BatchResponse)
|
| 1952 |
+
async def extensive_job_result(
|
| 1953 |
+
job_id: str,
|
| 1954 |
+
username: str = Depends(get_current_user)
|
| 1955 |
+
):
|
| 1956 |
+
"""Get result of a completed extensive generation job. Returns 404 if not found, 425 if still running."""
|
| 1957 |
+
if job_id not in _extensive_jobs:
|
| 1958 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 1959 |
+
job = _extensive_jobs[job_id]
|
| 1960 |
+
if job["username"] != username:
|
| 1961 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 1962 |
+
if job["status"] == "running":
|
| 1963 |
+
raise HTTPException(status_code=425, detail="Generation still in progress")
|
| 1964 |
+
if job["status"] == "failed":
|
| 1965 |
+
raise HTTPException(status_code=500, detail=job.get("error", "Generation failed"))
|
| 1966 |
+
return job["result"]
|
| 1967 |
|
| 1968 |
|
| 1969 |
# =============================================================================
|
|
|
|
| 2659 |
# Note: /health has its own explicit route, so not listed here
|
| 2660 |
api_only_routes = [
|
| 2661 |
"auth/login", "api/correct", "api/download-image", "api/export/bulk",
|
| 2662 |
+
"db/stats", "db/ads", "strategies", "extensive/generate", "extensive/status", "extensive/result"
|
| 2663 |
]
|
| 2664 |
|
| 2665 |
# Routes that are API for POST but frontend for GET
|