Added Tone and updated the banner prompt
Browse files- app/ads/budget_routes.py +0 -29
- app/ads/descriptions_service.py +6 -11
- app/ads/headings_service.py +5 -21
- app/ads/image_service.py +102 -19
- app/ads/persona_routes.py +24 -5
- app/ads/persona_service.py +12 -45
- app/ads/schemas.py +29 -12
- app/main.py +0 -2
app/ads/budget_routes.py
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 1 |
-
# app/routes/budget_routes.py
|
| 2 |
-
import logging
|
| 3 |
-
from fastapi import APIRouter, HTTPException
|
| 4 |
-
from typing import List
|
| 5 |
-
|
| 6 |
-
from app.ads.schemas import BudgetRequest, BudgetPlan
|
| 7 |
-
from app.ads.budget_service import generate_budget_plans
|
| 8 |
-
|
| 9 |
-
router = APIRouter(prefix="/Ads", tags=["Ads"])
|
| 10 |
-
logger = logging.getLogger(__name__)
|
| 11 |
-
logger.addHandler(logging.NullHandler())
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
@router.post("/price", response_model=List[BudgetPlan])
|
| 15 |
-
def create_budget_options(payload: BudgetRequest):
|
| 16 |
-
"""
|
| 17 |
-
Generate two budget options (daily & lifetime) for ad campaigns based on business inputs.
|
| 18 |
-
Returns a list of two objects:
|
| 19 |
-
[
|
| 20 |
-
{"type":"daily","budget":"25$/day","duration":"7 days"},
|
| 21 |
-
{"type":"lifetime","budget":"15$/day","duration":"62 days"}
|
| 22 |
-
]
|
| 23 |
-
"""
|
| 24 |
-
try:
|
| 25 |
-
plans = generate_budget_plans(payload)
|
| 26 |
-
return plans
|
| 27 |
-
except Exception as e:
|
| 28 |
-
logger.exception("Failed to generate budget plans: %s", e)
|
| 29 |
-
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/ads/descriptions_service.py
CHANGED
|
@@ -12,7 +12,6 @@ from app.ads.schemas import DescriptionsRequest, Persona
|
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
logger.addHandler(logging.NullHandler())
|
| 14 |
|
| 15 |
-
# Ensure genai configured (harmless if already configured elsewhere)
|
| 16 |
API_KEY = os.getenv("GEMINI_API_KEY")
|
| 17 |
if API_KEY:
|
| 18 |
try:
|
|
@@ -21,7 +20,6 @@ if API_KEY:
|
|
| 21 |
except Exception:
|
| 22 |
logger.exception("Failed to configure google.generativeai in descriptions service")
|
| 23 |
|
| 24 |
-
|
| 25 |
def _extract_json_array(raw: str) -> str:
|
| 26 |
start = raw.find('[')
|
| 27 |
end = raw.rfind(']')
|
|
@@ -29,11 +27,7 @@ def _extract_json_array(raw: str) -> str:
|
|
| 29 |
return raw[start:end + 1]
|
| 30 |
return raw
|
| 31 |
|
| 32 |
-
|
| 33 |
def _build_descriptions_prompt(req: DescriptionsRequest) -> str:
|
| 34 |
-
"""
|
| 35 |
-
Build prompt asking Gemini to return ONLY a JSON array of strings (ad descriptions).
|
| 36 |
-
"""
|
| 37 |
try:
|
| 38 |
personas_json = json.dumps([p.dict() for p in req.selected_personas], indent=2)
|
| 39 |
except Exception:
|
|
@@ -41,17 +35,20 @@ def _build_descriptions_prompt(req: DescriptionsRequest) -> str:
|
|
| 41 |
|
| 42 |
main_goal_value = req.main_goal.value
|
| 43 |
main_goal_desc = getattr(req.main_goal, "description", "")
|
|
|
|
|
|
|
| 44 |
|
| 45 |
prompt = f"""
|
| 46 |
You are an expert ad copywriter specialized in short, high-converting ad descriptions for digital ads.
|
| 47 |
|
|
|
|
|
|
|
| 48 |
Task:
|
| 49 |
Produce exactly {req.num_descriptions} ad descriptions (each 1-2 short sentences) tailored to the business and the selected persona(s). RETURN ONLY a JSON array of strings (e.g. ["Desc 1", "Desc 2", ...]) and nothing else.
|
| 50 |
|
| 51 |
Requirements:
|
| 52 |
- Each description should be concise (max ~140 characters preferred), benefit-focused, and aligned with the business value and main goal.
|
| 53 |
-
-
|
| 54 |
-
- Include the main value or offer where appropriate (e.g., "MVP development", "AI integration", "fast time-to-market", "trusted SaaS partner").
|
| 55 |
- If the selected personas have different priorities, generate descriptions that address those priorities.
|
| 56 |
- Goal: "{main_goal_value}" β {main_goal_desc}
|
| 57 |
|
|
@@ -71,10 +68,9 @@ Selected persona(s) (use these to shape descriptions):
|
|
| 71 |
|
| 72 |
Now generate exactly {req.num_descriptions} unique ad descriptions as a JSON array of strings. No explanation, no extra text.
|
| 73 |
"""
|
| 74 |
-
logger.debug("Built descriptions prompt (len=%d) for business '%s'", len(prompt), req.business_name)
|
| 75 |
return prompt.strip()
|
| 76 |
|
| 77 |
-
|
| 78 |
def generate_descriptions(req: DescriptionsRequest) -> List[str]:
|
| 79 |
prompt = _build_descriptions_prompt(req)
|
| 80 |
|
|
@@ -97,7 +93,6 @@ def generate_descriptions(req: DescriptionsRequest) -> List[str]:
|
|
| 97 |
logger.exception("Gemini generate_content failed for descriptions")
|
| 98 |
raise RuntimeError(f"Gemini request failed: {e}")
|
| 99 |
|
| 100 |
-
# extract raw text
|
| 101 |
raw = None
|
| 102 |
try:
|
| 103 |
if response and hasattr(response, "text") and response.text:
|
|
|
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
logger.addHandler(logging.NullHandler())
|
| 14 |
|
|
|
|
| 15 |
API_KEY = os.getenv("GEMINI_API_KEY")
|
| 16 |
if API_KEY:
|
| 17 |
try:
|
|
|
|
| 20 |
except Exception:
|
| 21 |
logger.exception("Failed to configure google.generativeai in descriptions service")
|
| 22 |
|
|
|
|
| 23 |
def _extract_json_array(raw: str) -> str:
|
| 24 |
start = raw.find('[')
|
| 25 |
end = raw.rfind(']')
|
|
|
|
| 27 |
return raw[start:end + 1]
|
| 28 |
return raw
|
| 29 |
|
|
|
|
| 30 |
def _build_descriptions_prompt(req: DescriptionsRequest) -> str:
|
|
|
|
|
|
|
|
|
|
| 31 |
try:
|
| 32 |
personas_json = json.dumps([p.dict() for p in req.selected_personas], indent=2)
|
| 33 |
except Exception:
|
|
|
|
| 35 |
|
| 36 |
main_goal_value = req.main_goal.value
|
| 37 |
main_goal_desc = getattr(req.main_goal, "description", "")
|
| 38 |
+
tone_enum = getattr(req, "tone", None)
|
| 39 |
+
tone_value = tone_enum.value if tone_enum is not None else "Professional"
|
| 40 |
|
| 41 |
prompt = f"""
|
| 42 |
You are an expert ad copywriter specialized in short, high-converting ad descriptions for digital ads.
|
| 43 |
|
| 44 |
+
Tone: Use a "{tone_value}" tone for all descriptions. If tone is "Professional", write concise, formal copy. If "Casual / Friendly", be approachable and conversational. If "Bold / Persuasive", use urgency and strong value statements. If "Inspiring / Visionary", use motivational and future-focused language.
|
| 45 |
+
|
| 46 |
Task:
|
| 47 |
Produce exactly {req.num_descriptions} ad descriptions (each 1-2 short sentences) tailored to the business and the selected persona(s). RETURN ONLY a JSON array of strings (e.g. ["Desc 1", "Desc 2", ...]) and nothing else.
|
| 48 |
|
| 49 |
Requirements:
|
| 50 |
- Each description should be concise (max ~140 characters preferred), benefit-focused, and aligned with the business value and main goal.
|
| 51 |
+
- Include the main value or offer where appropriate.
|
|
|
|
| 52 |
- If the selected personas have different priorities, generate descriptions that address those priorities.
|
| 53 |
- Goal: "{main_goal_value}" β {main_goal_desc}
|
| 54 |
|
|
|
|
| 68 |
|
| 69 |
Now generate exactly {req.num_descriptions} unique ad descriptions as a JSON array of strings. No explanation, no extra text.
|
| 70 |
"""
|
| 71 |
+
logger.debug("Built descriptions prompt (len=%d) for business '%s' (tone=%s)", len(prompt), req.business_name, tone_value)
|
| 72 |
return prompt.strip()
|
| 73 |
|
|
|
|
| 74 |
def generate_descriptions(req: DescriptionsRequest) -> List[str]:
|
| 75 |
prompt = _build_descriptions_prompt(req)
|
| 76 |
|
|
|
|
| 93 |
logger.exception("Gemini generate_content failed for descriptions")
|
| 94 |
raise RuntimeError(f"Gemini request failed: {e}")
|
| 95 |
|
|
|
|
| 96 |
raw = None
|
| 97 |
try:
|
| 98 |
if response and hasattr(response, "text") and response.text:
|
app/ads/headings_service.py
CHANGED
|
@@ -12,7 +12,6 @@ from app.ads.schemas import HeadingsRequest, Persona
|
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
logger.addHandler(logging.NullHandler())
|
| 14 |
|
| 15 |
-
# Ensure Gemini SDK is configured (harmless if already configured elsewhere)
|
| 16 |
API_KEY = os.getenv("GEMINI_API_KEY")
|
| 17 |
if not API_KEY:
|
| 18 |
logger.error("GEMINI_API_KEY not set; headings generation will fail if called without configuration.")
|
|
@@ -23,39 +22,32 @@ else:
|
|
| 23 |
except Exception as e:
|
| 24 |
logger.exception("Failed to configure google.generativeai in headings service: %s", e)
|
| 25 |
|
| 26 |
-
|
| 27 |
def _extract_json_array(raw: str) -> str:
|
| 28 |
-
"""
|
| 29 |
-
Return the first JSON array substring from raw (from '[' to ']') to be robust
|
| 30 |
-
against extra commentary in model output.
|
| 31 |
-
"""
|
| 32 |
start = raw.find('[')
|
| 33 |
end = raw.rfind(']')
|
| 34 |
if start != -1 and end != -1 and end > start:
|
| 35 |
return raw[start:end + 1]
|
| 36 |
return raw
|
| 37 |
|
| 38 |
-
|
| 39 |
def _build_headings_prompt(req: HeadingsRequest) -> str:
|
| 40 |
-
"""
|
| 41 |
-
Build a clear prompt asking Gemini to return ONLY a JSON array of strings (headings).
|
| 42 |
-
"""
|
| 43 |
-
# Convert selected_personas to compact JSON for context
|
| 44 |
personas_json = json.dumps([p.dict() for p in req.selected_personas], indent=2)
|
| 45 |
|
| 46 |
main_goal_value = req.main_goal.value
|
| 47 |
main_goal_desc = getattr(req.main_goal, "description", "")
|
|
|
|
|
|
|
| 48 |
|
| 49 |
prompt = f"""
|
| 50 |
You are an expert copywriter specialized in short, high-converting ad headlines for digital ads.
|
| 51 |
|
|
|
|
|
|
|
| 52 |
Task:
|
| 53 |
Produce exactly {req.num_headings} short, punchy ad headings (strings) for a paid ad campaign that target the selected personas and align with the business goal. RETURN ONLY a JSON array of strings (e.g. ["Heading 1", "Heading 2", ...]) and nothing else.
|
| 54 |
|
| 55 |
Requirements:
|
| 56 |
- Each heading should be concise (max ~60 characters), benefit-focused, and tailored to the provided personas and business goal.
|
| 57 |
- Use active language and mention the key value when appropriate (e.g., "scale", "AI", "launch", "MVP", "secure funding", "reduce time-to-market").
|
| 58 |
-
- Vary the tone across the 4 headings (e.g., urgent, aspirational, trust-building, and practical).
|
| 59 |
- Avoid punctuation-only headlines, and do not include numbering in text.
|
| 60 |
- If the main goal is "{main_goal_value}", use that intention as a primary framing. Goal description: {main_goal_desc}
|
| 61 |
|
|
@@ -75,14 +67,10 @@ Selected persona(s) (use these to shape headings):
|
|
| 75 |
|
| 76 |
Now generate exactly {req.num_headings} unique ad headings as a JSON array of strings. No explanation, no extra text.
|
| 77 |
"""
|
| 78 |
-
logger.debug("Built headings prompt (len=%d) for business '%s'", len(prompt), req.business_name)
|
| 79 |
return prompt.strip()
|
| 80 |
|
| 81 |
-
|
| 82 |
def generate_headings(req: HeadingsRequest) -> List[str]:
|
| 83 |
-
"""
|
| 84 |
-
Call Gemini to generate ad headings and return list[str].
|
| 85 |
-
"""
|
| 86 |
prompt = _build_headings_prompt(req)
|
| 87 |
|
| 88 |
model_name = "gemini-2.5-pro"
|
|
@@ -104,7 +92,6 @@ def generate_headings(req: HeadingsRequest) -> List[str]:
|
|
| 104 |
logger.exception("Gemini generate_content failed for headings")
|
| 105 |
raise RuntimeError(f"Gemini request failed: {e}")
|
| 106 |
|
| 107 |
-
# Extract raw text from response
|
| 108 |
raw = None
|
| 109 |
try:
|
| 110 |
if response and hasattr(response, "text") and response.text:
|
|
@@ -129,7 +116,6 @@ def generate_headings(req: HeadingsRequest) -> List[str]:
|
|
| 129 |
logger.error("Empty response from Gemini when generating headings")
|
| 130 |
raise RuntimeError("Empty response from Gemini")
|
| 131 |
|
| 132 |
-
# Robust JSON extraction & parsing
|
| 133 |
snippet = _extract_json_array(raw)
|
| 134 |
try:
|
| 135 |
parsed = json.loads(snippet)
|
|
@@ -141,7 +127,6 @@ def generate_headings(req: HeadingsRequest) -> List[str]:
|
|
| 141 |
logger.error("Parsed headings JSON is not a list of strings. Parsed type: %s", type(parsed))
|
| 142 |
raise RuntimeError("Gemini did not return a JSON array of strings as expected.")
|
| 143 |
|
| 144 |
-
# Normalize: ensure exactly num_headings items
|
| 145 |
headings = parsed
|
| 146 |
if len(headings) < req.num_headings:
|
| 147 |
logger.warning("Gemini returned %d headings; expected %d. Returning what we have.",
|
|
@@ -150,7 +135,6 @@ def generate_headings(req: HeadingsRequest) -> List[str]:
|
|
| 150 |
headings = headings[: req.num_headings]
|
| 151 |
logger.debug("Trimmed headings to requested num_headings=%d", req.num_headings)
|
| 152 |
|
| 153 |
-
# Final basic cleanup (strip whitespace)
|
| 154 |
headings = [h.strip() for h in headings]
|
| 155 |
|
| 156 |
logger.info("Generated %d headings for business '%s'", len(headings), req.business_name)
|
|
|
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
logger.addHandler(logging.NullHandler())
|
| 14 |
|
|
|
|
| 15 |
API_KEY = os.getenv("GEMINI_API_KEY")
|
| 16 |
if not API_KEY:
|
| 17 |
logger.error("GEMINI_API_KEY not set; headings generation will fail if called without configuration.")
|
|
|
|
| 22 |
except Exception as e:
|
| 23 |
logger.exception("Failed to configure google.generativeai in headings service: %s", e)
|
| 24 |
|
|
|
|
| 25 |
def _extract_json_array(raw: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
start = raw.find('[')
|
| 27 |
end = raw.rfind(']')
|
| 28 |
if start != -1 and end != -1 and end > start:
|
| 29 |
return raw[start:end + 1]
|
| 30 |
return raw
|
| 31 |
|
|
|
|
| 32 |
def _build_headings_prompt(req: HeadingsRequest) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
personas_json = json.dumps([p.dict() for p in req.selected_personas], indent=2)
|
| 34 |
|
| 35 |
main_goal_value = req.main_goal.value
|
| 36 |
main_goal_desc = getattr(req.main_goal, "description", "")
|
| 37 |
+
tone_enum = getattr(req, "tone", None)
|
| 38 |
+
tone_value = tone_enum.value if tone_enum is not None else "Professional"
|
| 39 |
|
| 40 |
prompt = f"""
|
| 41 |
You are an expert copywriter specialized in short, high-converting ad headlines for digital ads.
|
| 42 |
|
| 43 |
+
Tone: Use a "{tone_value}" tone for all headings. If tone is "Professional", keep headlines formal and trustworthy. If "Casual / Friendly", keep them approachable. If "Bold / Persuasive", use urgency and commanding language. If "Inspiring / Visionary", use aspirational language.
|
| 44 |
+
|
| 45 |
Task:
|
| 46 |
Produce exactly {req.num_headings} short, punchy ad headings (strings) for a paid ad campaign that target the selected personas and align with the business goal. RETURN ONLY a JSON array of strings (e.g. ["Heading 1", "Heading 2", ...]) and nothing else.
|
| 47 |
|
| 48 |
Requirements:
|
| 49 |
- Each heading should be concise (max ~60 characters), benefit-focused, and tailored to the provided personas and business goal.
|
| 50 |
- Use active language and mention the key value when appropriate (e.g., "scale", "AI", "launch", "MVP", "secure funding", "reduce time-to-market").
|
|
|
|
| 51 |
- Avoid punctuation-only headlines, and do not include numbering in text.
|
| 52 |
- If the main goal is "{main_goal_value}", use that intention as a primary framing. Goal description: {main_goal_desc}
|
| 53 |
|
|
|
|
| 67 |
|
| 68 |
Now generate exactly {req.num_headings} unique ad headings as a JSON array of strings. No explanation, no extra text.
|
| 69 |
"""
|
| 70 |
+
logger.debug("Built headings prompt (len=%d) for business '%s' (tone=%s)", len(prompt), req.business_name, tone_value)
|
| 71 |
return prompt.strip()
|
| 72 |
|
|
|
|
| 73 |
def generate_headings(req: HeadingsRequest) -> List[str]:
|
|
|
|
|
|
|
|
|
|
| 74 |
prompt = _build_headings_prompt(req)
|
| 75 |
|
| 76 |
model_name = "gemini-2.5-pro"
|
|
|
|
| 92 |
logger.exception("Gemini generate_content failed for headings")
|
| 93 |
raise RuntimeError(f"Gemini request failed: {e}")
|
| 94 |
|
|
|
|
| 95 |
raw = None
|
| 96 |
try:
|
| 97 |
if response and hasattr(response, "text") and response.text:
|
|
|
|
| 116 |
logger.error("Empty response from Gemini when generating headings")
|
| 117 |
raise RuntimeError("Empty response from Gemini")
|
| 118 |
|
|
|
|
| 119 |
snippet = _extract_json_array(raw)
|
| 120 |
try:
|
| 121 |
parsed = json.loads(snippet)
|
|
|
|
| 127 |
logger.error("Parsed headings JSON is not a list of strings. Parsed type: %s", type(parsed))
|
| 128 |
raise RuntimeError("Gemini did not return a JSON array of strings as expected.")
|
| 129 |
|
|
|
|
| 130 |
headings = parsed
|
| 131 |
if len(headings) < req.num_headings:
|
| 132 |
logger.warning("Gemini returned %d headings; expected %d. Returning what we have.",
|
|
|
|
| 135 |
headings = headings[: req.num_headings]
|
| 136 |
logger.debug("Trimmed headings to requested num_headings=%d", req.num_headings)
|
| 137 |
|
|
|
|
| 138 |
headings = [h.strip() for h in headings]
|
| 139 |
|
| 140 |
logger.info("Generated %d headings for business '%s'", len(headings), req.business_name)
|
app/ads/image_service.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
import io
|
| 2 |
import logging
|
| 3 |
-
from typing import Tuple
|
| 4 |
from google import genai
|
| 5 |
from google.genai import types
|
| 6 |
|
| 7 |
-
|
|
|
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
|
@@ -15,30 +16,109 @@ def _persona_to_text(persona: Persona) -> str:
|
|
| 15 |
|
| 16 |
def generate_image(req: ImageRequest) -> Tuple[bytes, str]:
|
| 17 |
"""
|
| 18 |
-
Generate an Ad image using Gemini
|
| 19 |
-
Falls back to returning text prompts if Gemini returns only text.
|
| 20 |
"""
|
|
|
|
|
|
|
| 21 |
# Safely convert Persona objects into strings
|
| 22 |
if req.selected_personas and len(req.selected_personas) > 0:
|
| 23 |
personas_text = "; ".join(_persona_to_text(p) for p in req.selected_personas)
|
| 24 |
else:
|
| 25 |
personas_text = "general target audience"
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
f"
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
logger.info("Requesting Gemini image generation for business '%s'", req.business_name)
|
|
|
|
| 42 |
|
| 43 |
try:
|
| 44 |
client = genai.Client()
|
|
@@ -61,10 +141,13 @@ def generate_image(req: ImageRequest) -> Tuple[bytes, str]:
|
|
| 61 |
for part in response.candidates[0].content.parts:
|
| 62 |
if getattr(part, "text", None):
|
| 63 |
logger.warning("Gemini returned text instead of image: %s", part.text[:300])
|
| 64 |
-
raise RuntimeError("Gemini returned text only
|
| 65 |
|
| 66 |
raise RuntimeError("No image data found in Gemini response.")
|
| 67 |
|
|
|
|
|
|
|
|
|
|
| 68 |
except Exception as e:
|
| 69 |
logger.exception("Gemini image generation failed: %s", e)
|
| 70 |
-
raise RuntimeError(f"Gemini image generation failed: {e}")
|
|
|
|
| 1 |
import io
|
| 2 |
import logging
|
| 3 |
+
from typing import Tuple, List, Optional
|
| 4 |
from google import genai
|
| 5 |
from google.genai import types
|
| 6 |
|
| 7 |
+
# Assuming this is your schema file
|
| 8 |
+
from app.ads.schemas import ImageRequest, Persona, ToneEnum, GoalEnum
|
| 9 |
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
|
|
|
|
| 16 |
|
| 17 |
def generate_image(req: ImageRequest) -> Tuple[bytes, str]:
|
| 18 |
"""
|
| 19 |
+
Generate an Ad image using Gemini with a highly-structured prompt.
|
|
|
|
| 20 |
"""
|
| 21 |
+
# --- 1. Prepare dynamic prompt components ---
|
| 22 |
+
|
| 23 |
# Safely convert Persona objects into strings
|
| 24 |
if req.selected_personas and len(req.selected_personas) > 0:
|
| 25 |
personas_text = "; ".join(_persona_to_text(p) for p in req.selected_personas)
|
| 26 |
else:
|
| 27 |
personas_text = "general target audience"
|
| 28 |
|
| 29 |
+
# Define aspect ratio
|
| 30 |
+
aspect_ratio_str = f"{req.width}:{req.height}"
|
| 31 |
+
if aspect_ratio_str == "1200:628":
|
| 32 |
+
aspect_ratio_desc = "1.91:1 (Landscape Link Ad)"
|
| 33 |
+
elif aspect_ratio_str == "1080:1080":
|
| 34 |
+
aspect_ratio_desc = "1:1 (Square Feed Ad)"
|
| 35 |
+
elif aspect_ratio_str == "1080:1920":
|
| 36 |
+
aspect_ratio_desc = "9:16 (Vertical Story Ad)"
|
| 37 |
+
else:
|
| 38 |
+
aspect_ratio_desc = f"{aspect_ratio_str} (Custom)"
|
| 39 |
+
|
| 40 |
+
# Create strings for optional fields
|
| 41 |
+
brand_color_str = ""
|
| 42 |
+
if req.brand_colors:
|
| 43 |
+
brand_color_str = f"* **Color Palette:** Strictly use this palette as the primary and accent colors: {', '.join(req.brand_colors)}."
|
| 44 |
+
else:
|
| 45 |
+
brand_color_str = f"* **Color Palette:** Use a professional color palette (e.g., blues, greys, with one strong accent color) that matches the '{req.tone.value}' tone."
|
| 46 |
+
|
| 47 |
+
visual_pref_text = ""
|
| 48 |
+
if req.visual_preference:
|
| 49 |
+
visual_pref_text = f"The main visual *must* be: **'{req.visual_preference}'**. This should be the element that catches the eye first."
|
| 50 |
+
else:
|
| 51 |
+
visual_pref_text = "Create a single, strong, and uncluttered focal point that represents the offer (e.g., an abstract graphic of growth, a clean icon, or a person matching the persona)."
|
| 52 |
+
|
| 53 |
+
cta_text_str = ""
|
| 54 |
+
if req.cta_text:
|
| 55 |
+
cta_text_str = f"The banner *must* also include the *exact* phrase: **'{req.cta_text}'**. This can be in a button-like shape or as clear, bold text."
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# --- 2. The New, Detailed, and Structured Prompt ---
|
| 59 |
+
|
| 60 |
+
prompt = f'''
|
| 61 |
+
**ROLE:** You are an expert Art Director at a world-class advertising agency.
|
| 62 |
+
**YOUR GOAL:** To generate a *single*, professional, and high-conversion ad banner for a Meta (Facebook/Instagram) campaign.
|
| 63 |
+
**YOUR TASK:** Generate one (1) image file based *only* on the strict hierarchy, components, and constraints below.
|
| 64 |
+
|
| 65 |
+
---
|
| 66 |
+
|
| 67 |
+
### 1. HIERARCHY OF IMPORTANCE (Strict Priority)
|
| 68 |
+
|
| 69 |
+
This is the order of what the user MUST see. Your design must follow this.
|
| 70 |
+
|
| 71 |
+
* **P1: FOCAL POINT (The Visual):**
|
| 72 |
+
* This is the **most important** element. It must stop the user from scrolling.
|
| 73 |
+
* It must be a single, clean, high-quality, professional image or graphic.
|
| 74 |
+
* **Visual Content:** {visual_pref_text}
|
| 75 |
+
* It must be the primary focus of the entire banner.
|
| 76 |
+
|
| 77 |
+
* **P2: HEADLINE (The Offer):**
|
| 78 |
+
* This is the **second most important** element. It is the primary text.
|
| 79 |
+
* It must be bold, clear, and easy to read in 1 second.
|
| 80 |
+
* **Text Content:** It must be the *exact* phrase: **'{req.offer_description}'**
|
| 81 |
+
|
| 82 |
+
* **P3: CALL-TO-ACTION (The Instruction):**
|
| 83 |
+
* This is the **third most important** element. It tells the user what to do next.
|
| 84 |
+
* It must be visually distinct (e.g., a button shape or contrasting bold text).
|
| 85 |
+
* **Text Content:** {cta_text_str}
|
| 86 |
+
|
| 87 |
+
* **P4: BRANDING (The Signature):**
|
| 88 |
+
* This is the **least important** element. It is for brand recognition only.
|
| 89 |
+
* It must be small, clean, and placed in a corner (e.g., bottom-left or top-right).
|
| 90 |
+
* **Text Content:** **'{req.business_name}'** (Render this as clean text, not a complex logo).
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
### 2. DESIGN & LAYOUT MANDATES
|
| 95 |
+
|
| 96 |
+
* **ASPECT RATIO:** **{aspect_ratio_desc} ({req.width}x{req.height}px).** This is a non-negotiable technical requirement.
|
| 97 |
+
* **STYLE & TONE:** **{req.style}, {req.tone.value}.**
|
| 98 |
+
* **TARGET AUDIENCE CONTEXT:** The entire design (font choice, imagery, colors) must feel professional and trustworthy to this user: **{personas_text}**.
|
| 99 |
+
* **COLOR PALETTE:** {brand_color_str}
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
### 3. WHAT TO AVOID (CRITICAL FAILURES)
|
| 104 |
+
|
| 105 |
+
* **DO NOT** add *any text* not explicitly listed in P2, P3, or P4.
|
| 106 |
+
* **DO NOT** clutter the image. White space is essential. The design must be **minimalist** and **clean**.
|
| 107 |
+
* **DO NOT** use generic, cheesy, or low-quality stock photos.
|
| 108 |
+
* **DO NOT** use hard-to-read, cursive, or overly-stylized fonts. Readability is the top priority for text.
|
| 109 |
+
* **DO NOT** make the branding (P4) large or the main focus. It must be subtle.
|
| 110 |
+
|
| 111 |
+
---
|
| 112 |
+
|
| 113 |
+
### FINAL OUTPUT
|
| 114 |
+
|
| 115 |
+
Generate *only* the final, polished ad banner image. Do not respond with text, questions, or comments.
|
| 116 |
+
'''
|
| 117 |
+
|
| 118 |
+
# --- 3. Execute the API Call ---
|
| 119 |
|
| 120 |
logger.info("Requesting Gemini image generation for business '%s'", req.business_name)
|
| 121 |
+
logger.debug("Full prompt:\n%s", prompt) # Good for debugging
|
| 122 |
|
| 123 |
try:
|
| 124 |
client = genai.Client()
|
|
|
|
| 141 |
for part in response.candidates[0].content.parts:
|
| 142 |
if getattr(part, "text", None):
|
| 143 |
logger.warning("Gemini returned text instead of image: %s", part.text[:300])
|
| 144 |
+
raise RuntimeError(f"Gemini returned text only: {part.text[:300]}...")
|
| 145 |
|
| 146 |
raise RuntimeError("No image data found in Gemini response.")
|
| 147 |
|
| 148 |
+
except AttributeError as e:
|
| 149 |
+
logger.exception(f"Failed to parse Gemini response. It's possible the API response structure is unexpected. {e}")
|
| 150 |
+
raise RuntimeError(f"Failed to parse Gemini response: {e}")
|
| 151 |
except Exception as e:
|
| 152 |
logger.exception("Gemini image generation failed: %s", e)
|
| 153 |
+
raise RuntimeError(f"Gemini image generation failed: {e}")
|
app/ads/persona_routes.py
CHANGED
|
@@ -3,12 +3,22 @@ from fastapi import APIRouter, HTTPException
|
|
| 3 |
from typing import List
|
| 4 |
from fastapi.responses import StreamingResponse
|
| 5 |
|
| 6 |
-
from app.ads.schemas import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import io
|
| 8 |
import app.ads.image_service as image_service
|
| 9 |
import app.ads.headings_service as headings_service
|
| 10 |
import app.ads.descriptions_service as descriptions_service
|
| 11 |
-
from app.ads.persona_service import generate_personas
|
|
|
|
| 12 |
|
| 13 |
router = APIRouter(prefix="/Ads", tags=["Ads"])
|
| 14 |
|
|
@@ -29,7 +39,6 @@ def regenerate_personas_endpoint(payload: RegenerateRequest):
|
|
| 29 |
try:
|
| 30 |
personas = regenerate_personas(payload, payload.previous_personas)
|
| 31 |
except Exception as e:
|
| 32 |
-
# return the error message to the client for quick debugging
|
| 33 |
raise HTTPException(status_code=500, detail=str(e))
|
| 34 |
return personas
|
| 35 |
|
|
@@ -41,7 +50,6 @@ def create_headings(payload: HeadingsRequest):
|
|
| 41 |
try:
|
| 42 |
return headings_service.generate_headings(payload)
|
| 43 |
except Exception as e:
|
| 44 |
-
# Log error server-side; return HTTP 500 with message for debugging
|
| 45 |
raise HTTPException(status_code=500, detail=str(e))
|
| 46 |
|
| 47 |
@router.post("/Descriptions", response_model=List[str])
|
|
@@ -60,4 +68,15 @@ def create_image(payload: ImageRequest):
|
|
| 60 |
img_bytes, mime = image_service.generate_image(payload)
|
| 61 |
return StreamingResponse(io.BytesIO(img_bytes), media_type=mime)
|
| 62 |
except Exception as e:
|
| 63 |
-
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from typing import List
|
| 4 |
from fastapi.responses import StreamingResponse
|
| 5 |
|
| 6 |
+
from app.ads.schemas import (
|
| 7 |
+
BusinessInput,
|
| 8 |
+
Persona,
|
| 9 |
+
RegenerateRequest,
|
| 10 |
+
HeadingsRequest,
|
| 11 |
+
DescriptionsRequest,
|
| 12 |
+
ImageRequest,
|
| 13 |
+
BudgetRequest,
|
| 14 |
+
BudgetPlan,
|
| 15 |
+
)
|
| 16 |
import io
|
| 17 |
import app.ads.image_service as image_service
|
| 18 |
import app.ads.headings_service as headings_service
|
| 19 |
import app.ads.descriptions_service as descriptions_service
|
| 20 |
+
from app.ads.persona_service import generate_personas, regenerate_personas
|
| 21 |
+
from app.ads.budget_service import generate_budget_plans
|
| 22 |
|
| 23 |
router = APIRouter(prefix="/Ads", tags=["Ads"])
|
| 24 |
|
|
|
|
| 39 |
try:
|
| 40 |
personas = regenerate_personas(payload, payload.previous_personas)
|
| 41 |
except Exception as e:
|
|
|
|
| 42 |
raise HTTPException(status_code=500, detail=str(e))
|
| 43 |
return personas
|
| 44 |
|
|
|
|
| 50 |
try:
|
| 51 |
return headings_service.generate_headings(payload)
|
| 52 |
except Exception as e:
|
|
|
|
| 53 |
raise HTTPException(status_code=500, detail=str(e))
|
| 54 |
|
| 55 |
@router.post("/Descriptions", response_model=List[str])
|
|
|
|
| 68 |
img_bytes, mime = image_service.generate_image(payload)
|
| 69 |
return StreamingResponse(io.BytesIO(img_bytes), media_type=mime)
|
| 70 |
except Exception as e:
|
| 71 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 72 |
+
|
| 73 |
+
@router.post("/price", response_model=List[BudgetPlan])
|
| 74 |
+
def create_budget_options(payload: BudgetRequest):
|
| 75 |
+
"""
|
| 76 |
+
Generate two budget options (daily & lifetime) for ad campaigns based on business inputs.
|
| 77 |
+
"""
|
| 78 |
+
try:
|
| 79 |
+
plans = generate_budget_plans(payload)
|
| 80 |
+
return plans
|
| 81 |
+
except Exception as e:
|
| 82 |
+
raise HTTPException(status_code=500, detail=str(e))
|
app/ads/persona_service.py
CHANGED
|
@@ -9,20 +9,15 @@ from pydantic import ValidationError
|
|
| 9 |
|
| 10 |
from app.ads.schemas import Persona, BusinessInput
|
| 11 |
|
| 12 |
-
# module logger
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
-
# Avoid configuring global logging here; app-level config should set handlers/levels.
|
| 15 |
-
# But ensure we have at least a NullHandler to avoid "No handler" warnings in some apps.
|
| 16 |
logger.addHandler(logging.NullHandler())
|
| 17 |
|
| 18 |
-
|
| 19 |
# initialize client (reads GEMINI_API_KEY from environment)
|
| 20 |
API_KEY = os.getenv("GEMINI_API_KEY")
|
| 21 |
if not API_KEY:
|
| 22 |
logger.error("GEMINI_API_KEY environment variable not set")
|
| 23 |
raise RuntimeError("Please set the GEMINI_API_KEY environment variable")
|
| 24 |
|
| 25 |
-
# Configure the genai SDK (reference pattern)
|
| 26 |
try:
|
| 27 |
genai.configure(api_key=API_KEY)
|
| 28 |
logger.info("Configured google.generativeai with provided API key")
|
|
@@ -30,9 +25,7 @@ except Exception as e:
|
|
| 30 |
logger.exception("Failed to configure google.generativeai: %s", e)
|
| 31 |
raise
|
| 32 |
|
| 33 |
-
|
| 34 |
def _build_prompt(b: BusinessInput) -> str:
|
| 35 |
-
|
| 36 |
examples_json = [
|
| 37 |
{
|
| 38 |
"name": "Startup Founders",
|
|
@@ -74,13 +67,16 @@ def _build_prompt(b: BusinessInput) -> str:
|
|
| 74 |
}
|
| 75 |
]
|
| 76 |
|
| 77 |
-
# Use both the enum value and the associated description
|
| 78 |
main_goal_value = b.main_goal.value
|
| 79 |
main_goal_desc = getattr(b.main_goal, "description", "")
|
|
|
|
|
|
|
| 80 |
|
| 81 |
prompt = f'''
|
| 82 |
You are a senior marketing strategist specialized in creating *ideal-customer / target-audience personas* for businesses. Produce exactly {b.num_personas} distinct IDEAL-CUSTOMER personas tailored to the business described below.
|
| 83 |
|
|
|
|
|
|
|
| 84 |
**Output format (required):** Return ONLY a JSON array of objects. Each object must contain these properties in this exact order:
|
| 85 |
1. name (string)
|
| 86 |
2. headline (string; 3-6 words)
|
|
@@ -95,11 +91,10 @@ You are a senior marketing strategist specialized in creating *ideal-customer /
|
|
| 95 |
- primary buying trigger or motivation,
|
| 96 |
- preferred channels to reach them (e.g., Instagram, LinkedIn, email, local events),
|
| 97 |
- why they would choose this business / how the offer solves their need.
|
|
|
|
| 98 |
|
| 99 |
**Do NOT include any extra top-level keys, comments, or explanation text. JSON array only.**
|
| 100 |
|
| 101 |
-
Below are three example personas showing the exact style and level of detail I want your output to match. Use them as format examples β but produce personas specific to the business inputs that follow.
|
| 102 |
-
|
| 103 |
EXAMPLE PERSONAS (format example):
|
| 104 |
{json.dumps(examples_json, indent=2)}
|
| 105 |
|
|
@@ -117,15 +112,10 @@ Business inputs:
|
|
| 117 |
Generate the {b.num_personas} personas now as a JSON array that exactly matches the schema and style shown above.
|
| 118 |
'''
|
| 119 |
built = prompt.strip()
|
| 120 |
-
logger.debug("Built persona prompt (goal=%s): %s", main_goal_value, built[:400] + ("β¦" if len(built) > 400 else ""))
|
| 121 |
return built
|
| 122 |
|
| 123 |
-
|
| 124 |
def _extract_json_array(raw: str) -> str:
|
| 125 |
-
"""
|
| 126 |
-
Find and return the first JSON array substring in raw text (from '[' to ']').
|
| 127 |
-
If not found, return raw as-is (parsing will attempt).
|
| 128 |
-
"""
|
| 129 |
start = raw.find('[')
|
| 130 |
end = raw.rfind(']')
|
| 131 |
if start != -1 and end != -1 and end > start:
|
|
@@ -135,12 +125,7 @@ def _extract_json_array(raw: str) -> str:
|
|
| 135 |
logger.debug("No JSON array brackets found in raw response; returning full raw text for parsing")
|
| 136 |
return raw
|
| 137 |
|
| 138 |
-
|
| 139 |
def generate_personas(b: BusinessInput) -> List[Persona]:
|
| 140 |
-
"""
|
| 141 |
-
Generate personas using Gemini. Returns a list of Persona Pydantic models.
|
| 142 |
-
Logs important steps and errors for easier debugging.
|
| 143 |
-
"""
|
| 144 |
prompt = _build_prompt(b)
|
| 145 |
|
| 146 |
try:
|
|
@@ -155,11 +140,9 @@ def generate_personas(b: BusinessInput) -> List[Persona]:
|
|
| 155 |
logger.info("Gemini generate_content completed in %.2fs", duration)
|
| 156 |
|
| 157 |
except Exception as e:
|
| 158 |
-
# surface Gemini initialization / network errors with stack trace
|
| 159 |
logger.exception("Gemini request failed for business '%s': %s", b.business_name, e)
|
| 160 |
raise RuntimeError(f"Gemini request failed: {e}")
|
| 161 |
|
| 162 |
-
# Inspect response for text or safety block
|
| 163 |
raw = None
|
| 164 |
try:
|
| 165 |
if response and hasattr(response, "text") and response.text:
|
|
@@ -184,17 +167,13 @@ def generate_personas(b: BusinessInput) -> List[Persona]:
|
|
| 184 |
logger.error("Empty response received from Gemini for business '%s'", b.business_name)
|
| 185 |
raise RuntimeError("Empty response received from Gemini")
|
| 186 |
|
| 187 |
-
# Extract JSON array substring (robust for extra commentary)
|
| 188 |
json_snippet = _extract_json_array(raw)
|
| 189 |
logger.info("Attempting to parse JSON snippet for business '%s' (snippet length=%d)", b.business_name, len(json_snippet))
|
| 190 |
|
| 191 |
-
# Attempt to parse JSON and validate against Persona schema
|
| 192 |
try:
|
| 193 |
parsed = json.loads(json_snippet)
|
| 194 |
|
| 195 |
-
# If model returned a dict wrapper, attempt to find the list inside
|
| 196 |
if isinstance(parsed, dict):
|
| 197 |
-
# common wrapper keys to check
|
| 198 |
for key in ("items", "personas", "data", "results"):
|
| 199 |
if key in parsed and isinstance(parsed[key], list):
|
| 200 |
parsed = parsed[key]
|
|
@@ -212,7 +191,6 @@ def generate_personas(b: BusinessInput) -> List[Persona]:
|
|
| 212 |
personas.append(persona)
|
| 213 |
logger.debug("Validated persona %d: %s", idx, persona.name)
|
| 214 |
except ValidationError as ve:
|
| 215 |
-
# include which item failed for better debugging
|
| 216 |
logger.error("Persona validation failed for item %s: %s\nRaw item: %s", idx, ve, obj)
|
| 217 |
raise
|
| 218 |
|
|
@@ -220,35 +198,29 @@ def generate_personas(b: BusinessInput) -> List[Persona]:
|
|
| 220 |
return personas
|
| 221 |
|
| 222 |
except (json.JSONDecodeError, ValidationError, ValueError) as e:
|
| 223 |
-
# Provide helpful debug output including raw Gemini text
|
| 224 |
logger.exception("Failed to parse/validate Gemini JSON output for business '%s'", b.business_name)
|
| 225 |
raise RuntimeError(
|
| 226 |
f"Failed to parse Gemini response as JSON Personas: {e}\n\nRaw Gemini response:\n{raw}"
|
| 227 |
)
|
| 228 |
|
| 229 |
def regenerate_personas(b: BusinessInput, previous_personas: List[Persona]) -> List[Persona]:
|
| 230 |
-
"""
|
| 231 |
-
Generate a new set of personas given the business input AND a list of
|
| 232 |
-
previously generated personas. The model is instructed to avoid duplicating
|
| 233 |
-
the previous personas and produce distinct/improved target-audience personas.
|
| 234 |
-
"""
|
| 235 |
-
# Build base prompt from existing function
|
| 236 |
base_prompt = _build_prompt(b)
|
| 237 |
|
| 238 |
-
# Convert previous_personas to simple dicts (Pydantic models -> plain dicts)
|
| 239 |
try:
|
| 240 |
prev_list = [p.dict() if hasattr(p, "dict") else p for p in previous_personas]
|
| 241 |
except Exception:
|
| 242 |
-
# Defensive: if previous_personas are plain dicts already
|
| 243 |
prev_list = previous_personas
|
| 244 |
|
| 245 |
prev_json = json.dumps(prev_list, indent=2)
|
|
|
|
|
|
|
| 246 |
|
| 247 |
-
# Append clear instructions about previous personas and uniqueness requirement
|
| 248 |
extra_instructions = f"""
|
| 249 |
Previous personas provided (do NOT repeat these exactly):
|
| 250 |
{prev_json}
|
| 251 |
|
|
|
|
|
|
|
| 252 |
Instructions:
|
| 253 |
- Produce exactly {b.num_personas} personas tailored to the same business inputs above.
|
| 254 |
- **Do not duplicate** persona names or core audience segments included in the previous list.
|
|
@@ -261,13 +233,11 @@ Instructions:
|
|
| 261 |
"""
|
| 262 |
prompt = base_prompt + "\n\n" + extra_instructions
|
| 263 |
|
| 264 |
-
logger.info("Regenerating personas for business '%s' with %d previous personas",
|
| 265 |
-
b.business_name, len(prev_list))
|
| 266 |
|
| 267 |
-
# call model (same pattern as generate_personas)
|
| 268 |
try:
|
| 269 |
model_name = "gemini-2.5-pro"
|
| 270 |
-
logger.info("Initializing Gemini model for regeneration: %s", model_name)
|
| 271 |
model = genai.GenerativeModel(model_name)
|
| 272 |
|
| 273 |
start_ts = time.perf_counter()
|
|
@@ -279,7 +249,6 @@ Instructions:
|
|
| 279 |
logger.exception("Gemini regenerate request failed for business '%s': %s", b.business_name, e)
|
| 280 |
raise RuntimeError(f"Gemini regenerate request failed: {e}")
|
| 281 |
|
| 282 |
-
# Extract raw text (same robust logic)
|
| 283 |
raw = None
|
| 284 |
try:
|
| 285 |
if response and hasattr(response, "text") and response.text:
|
|
@@ -304,7 +273,6 @@ Instructions:
|
|
| 304 |
logger.error("Empty regenerate response from Gemini for business '%s'", b.business_name)
|
| 305 |
raise RuntimeError("Empty response received from Gemini")
|
| 306 |
|
| 307 |
-
# Extract JSON and validate (reuse helper)
|
| 308 |
json_snippet = _extract_json_array(raw)
|
| 309 |
logger.info("Attempting to parse regenerated JSON snippet for business '%s' (len=%d)",
|
| 310 |
b.business_name, len(json_snippet))
|
|
@@ -312,7 +280,6 @@ Instructions:
|
|
| 312 |
try:
|
| 313 |
parsed = json.loads(json_snippet)
|
| 314 |
|
| 315 |
-
# If wrapper -> find list
|
| 316 |
if isinstance(parsed, dict):
|
| 317 |
for key in ("items", "personas", "data", "results"):
|
| 318 |
if key in parsed and isinstance(parsed[key], list):
|
|
|
|
| 9 |
|
| 10 |
from app.ads.schemas import Persona, BusinessInput
|
| 11 |
|
|
|
|
| 12 |
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
| 13 |
logger.addHandler(logging.NullHandler())
|
| 14 |
|
|
|
|
| 15 |
# initialize client (reads GEMINI_API_KEY from environment)
|
| 16 |
API_KEY = os.getenv("GEMINI_API_KEY")
|
| 17 |
if not API_KEY:
|
| 18 |
logger.error("GEMINI_API_KEY environment variable not set")
|
| 19 |
raise RuntimeError("Please set the GEMINI_API_KEY environment variable")
|
| 20 |
|
|
|
|
| 21 |
try:
|
| 22 |
genai.configure(api_key=API_KEY)
|
| 23 |
logger.info("Configured google.generativeai with provided API key")
|
|
|
|
| 25 |
logger.exception("Failed to configure google.generativeai: %s", e)
|
| 26 |
raise
|
| 27 |
|
|
|
|
| 28 |
def _build_prompt(b: BusinessInput) -> str:
|
|
|
|
| 29 |
examples_json = [
|
| 30 |
{
|
| 31 |
"name": "Startup Founders",
|
|
|
|
| 67 |
}
|
| 68 |
]
|
| 69 |
|
|
|
|
| 70 |
main_goal_value = b.main_goal.value
|
| 71 |
main_goal_desc = getattr(b.main_goal, "description", "")
|
| 72 |
+
tone_enum = getattr(b, "tone", None)
|
| 73 |
+
tone_value = tone_enum.value if tone_enum is not None else "Professional"
|
| 74 |
|
| 75 |
prompt = f'''
|
| 76 |
You are a senior marketing strategist specialized in creating *ideal-customer / target-audience personas* for businesses. Produce exactly {b.num_personas} distinct IDEAL-CUSTOMER personas tailored to the business described below.
|
| 77 |
|
| 78 |
+
**Tone:** Use a "{tone_value}" tone for all persona text (including descriptions and headlines). Apply the tone consistently. If tone is "Professional", use a formal, trustworthy voice. If "Casual / Friendly", be approachable and conversational. If "Bold / Persuasive", use urgency and strong calls-to-action. If "Inspiring / Visionary", use motivating, future-focused language.
|
| 79 |
+
|
| 80 |
**Output format (required):** Return ONLY a JSON array of objects. Each object must contain these properties in this exact order:
|
| 81 |
1. name (string)
|
| 82 |
2. headline (string; 3-6 words)
|
|
|
|
| 91 |
- primary buying trigger or motivation,
|
| 92 |
- preferred channels to reach them (e.g., Instagram, LinkedIn, email, local events),
|
| 93 |
- why they would choose this business / how the offer solves their need.
|
| 94 |
+
- Use the requested tone when phrasing motivations and channels.
|
| 95 |
|
| 96 |
**Do NOT include any extra top-level keys, comments, or explanation text. JSON array only.**
|
| 97 |
|
|
|
|
|
|
|
| 98 |
EXAMPLE PERSONAS (format example):
|
| 99 |
{json.dumps(examples_json, indent=2)}
|
| 100 |
|
|
|
|
| 112 |
Generate the {b.num_personas} personas now as a JSON array that exactly matches the schema and style shown above.
|
| 113 |
'''
|
| 114 |
built = prompt.strip()
|
| 115 |
+
logger.debug("Built persona prompt (goal=%s; tone=%s): %s", main_goal_value, tone_value, built[:400] + ("β¦" if len(built) > 400 else ""))
|
| 116 |
return built
|
| 117 |
|
|
|
|
| 118 |
def _extract_json_array(raw: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
start = raw.find('[')
|
| 120 |
end = raw.rfind(']')
|
| 121 |
if start != -1 and end != -1 and end > start:
|
|
|
|
| 125 |
logger.debug("No JSON array brackets found in raw response; returning full raw text for parsing")
|
| 126 |
return raw
|
| 127 |
|
|
|
|
| 128 |
def generate_personas(b: BusinessInput) -> List[Persona]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
prompt = _build_prompt(b)
|
| 130 |
|
| 131 |
try:
|
|
|
|
| 140 |
logger.info("Gemini generate_content completed in %.2fs", duration)
|
| 141 |
|
| 142 |
except Exception as e:
|
|
|
|
| 143 |
logger.exception("Gemini request failed for business '%s': %s", b.business_name, e)
|
| 144 |
raise RuntimeError(f"Gemini request failed: {e}")
|
| 145 |
|
|
|
|
| 146 |
raw = None
|
| 147 |
try:
|
| 148 |
if response and hasattr(response, "text") and response.text:
|
|
|
|
| 167 |
logger.error("Empty response received from Gemini for business '%s'", b.business_name)
|
| 168 |
raise RuntimeError("Empty response received from Gemini")
|
| 169 |
|
|
|
|
| 170 |
json_snippet = _extract_json_array(raw)
|
| 171 |
logger.info("Attempting to parse JSON snippet for business '%s' (snippet length=%d)", b.business_name, len(json_snippet))
|
| 172 |
|
|
|
|
| 173 |
try:
|
| 174 |
parsed = json.loads(json_snippet)
|
| 175 |
|
|
|
|
| 176 |
if isinstance(parsed, dict):
|
|
|
|
| 177 |
for key in ("items", "personas", "data", "results"):
|
| 178 |
if key in parsed and isinstance(parsed[key], list):
|
| 179 |
parsed = parsed[key]
|
|
|
|
| 191 |
personas.append(persona)
|
| 192 |
logger.debug("Validated persona %d: %s", idx, persona.name)
|
| 193 |
except ValidationError as ve:
|
|
|
|
| 194 |
logger.error("Persona validation failed for item %s: %s\nRaw item: %s", idx, ve, obj)
|
| 195 |
raise
|
| 196 |
|
|
|
|
| 198 |
return personas
|
| 199 |
|
| 200 |
except (json.JSONDecodeError, ValidationError, ValueError) as e:
|
|
|
|
| 201 |
logger.exception("Failed to parse/validate Gemini JSON output for business '%s'", b.business_name)
|
| 202 |
raise RuntimeError(
|
| 203 |
f"Failed to parse Gemini response as JSON Personas: {e}\n\nRaw Gemini response:\n{raw}"
|
| 204 |
)
|
| 205 |
|
| 206 |
def regenerate_personas(b: BusinessInput, previous_personas: List[Persona]) -> List[Persona]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
base_prompt = _build_prompt(b)
|
| 208 |
|
|
|
|
| 209 |
try:
|
| 210 |
prev_list = [p.dict() if hasattr(p, "dict") else p for p in previous_personas]
|
| 211 |
except Exception:
|
|
|
|
| 212 |
prev_list = previous_personas
|
| 213 |
|
| 214 |
prev_json = json.dumps(prev_list, indent=2)
|
| 215 |
+
tone_enum = getattr(b, "tone", None)
|
| 216 |
+
tone_value = tone_enum.value if tone_enum is not None else "Professional"
|
| 217 |
|
|
|
|
| 218 |
extra_instructions = f"""
|
| 219 |
Previous personas provided (do NOT repeat these exactly):
|
| 220 |
{prev_json}
|
| 221 |
|
| 222 |
+
Tone: Use a "{tone_value}" tone consistently in names and descriptions. If tone is "Professional" use a formal voice; if "Casual / Friendly" be conversational; if "Bold / Persuasive" be urgency-driven; if "Inspiring / Visionary" be motivational.
|
| 223 |
+
|
| 224 |
Instructions:
|
| 225 |
- Produce exactly {b.num_personas} personas tailored to the same business inputs above.
|
| 226 |
- **Do not duplicate** persona names or core audience segments included in the previous list.
|
|
|
|
| 233 |
"""
|
| 234 |
prompt = base_prompt + "\n\n" + extra_instructions
|
| 235 |
|
| 236 |
+
logger.info("Regenerating personas for business '%s' with %d previous personas (tone=%s)",
|
| 237 |
+
b.business_name, len(prev_list), tone_value)
|
| 238 |
|
|
|
|
| 239 |
try:
|
| 240 |
model_name = "gemini-2.5-pro"
|
|
|
|
| 241 |
model = genai.GenerativeModel(model_name)
|
| 242 |
|
| 243 |
start_ts = time.perf_counter()
|
|
|
|
| 249 |
logger.exception("Gemini regenerate request failed for business '%s': %s", b.business_name, e)
|
| 250 |
raise RuntimeError(f"Gemini regenerate request failed: {e}")
|
| 251 |
|
|
|
|
| 252 |
raw = None
|
| 253 |
try:
|
| 254 |
if response and hasattr(response, "text") and response.text:
|
|
|
|
| 273 |
logger.error("Empty regenerate response from Gemini for business '%s'", b.business_name)
|
| 274 |
raise RuntimeError("Empty response received from Gemini")
|
| 275 |
|
|
|
|
| 276 |
json_snippet = _extract_json_array(raw)
|
| 277 |
logger.info("Attempting to parse regenerated JSON snippet for business '%s' (len=%d)",
|
| 278 |
b.business_name, len(json_snippet))
|
|
|
|
| 280 |
try:
|
| 281 |
parsed = json.loads(json_snippet)
|
| 282 |
|
|
|
|
| 283 |
if isinstance(parsed, dict):
|
| 284 |
for key in ("items", "personas", "data", "results"):
|
| 285 |
if key in parsed and isinstance(parsed[key], list):
|
app/ads/schemas.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
from enum import Enum
|
| 2 |
from pydantic import BaseModel, Field
|
| 3 |
from typing import List, Optional
|
|
@@ -27,6 +28,12 @@ class GoalEnum(str, Enum):
|
|
| 27 |
obj.description = description
|
| 28 |
return obj
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
class Persona(BaseModel):
|
| 31 |
uuid: str = Field(default_factory=lambda: str(uuid4()), description="Unique identifier for this persona")
|
| 32 |
flag: bool = Field(False, description="Boolean flag for client use (defaults to False)")
|
|
@@ -48,7 +55,7 @@ class BusinessInput(BaseModel):
|
|
| 48 |
serving_clients_info: str
|
| 49 |
serving_clients_location: str
|
| 50 |
num_personas: int = 3
|
| 51 |
-
|
| 52 |
|
| 53 |
class RegenerateRequest(BusinessInput):
|
| 54 |
"""
|
|
@@ -58,7 +65,6 @@ class RegenerateRequest(BusinessInput):
|
|
| 58 |
"""
|
| 59 |
previous_personas: List[Persona]
|
| 60 |
|
| 61 |
-
|
| 62 |
class HeadingsRequest(BaseModel):
|
| 63 |
business_name: str = Field(..., example="GrowthAspired")
|
| 64 |
business_category: str = Field(..., example="Software House")
|
|
@@ -69,11 +75,9 @@ class HeadingsRequest(BaseModel):
|
|
| 69 |
main_goal: GoalEnum = Field(..., description="Primary marketing goal (enum)")
|
| 70 |
serving_clients_info: str
|
| 71 |
serving_clients_location: str
|
| 72 |
-
# list of previously generated or selected persona objects (use Persona model)
|
| 73 |
selected_personas: List[Persona] = Field(..., description="List of selected persona objects to target")
|
| 74 |
-
# optional: prefer number of headings (defaults to 4)
|
| 75 |
num_headings: Optional[int] = Field(4, description="How many headings to generate")
|
| 76 |
-
|
| 77 |
|
| 78 |
class DescriptionsRequest(BaseModel):
|
| 79 |
business_name: str = Field(..., example="GrowthAspired")
|
|
@@ -87,7 +91,7 @@ class DescriptionsRequest(BaseModel):
|
|
| 87 |
serving_clients_location: str
|
| 88 |
selected_personas: List[Persona] = Field(..., description="List of selected persona objects to target")
|
| 89 |
num_descriptions: Optional[int] = Field(4, description="How many ad descriptions to generate (default 4)")
|
| 90 |
-
|
| 91 |
|
| 92 |
class ImageRequest(BaseModel):
|
| 93 |
business_name: str = Field(..., example="GrowthAspired")
|
|
@@ -100,24 +104,37 @@ class ImageRequest(BaseModel):
|
|
| 100 |
serving_clients_info: str
|
| 101 |
serving_clients_location: str
|
| 102 |
selected_personas: List[Persona] = Field(..., description="List of selected persona objects to target")
|
| 103 |
-
# optional style/size params
|
| 104 |
style: Optional[str] = Field("modern", description="Desired art style or mood (e.g. modern, minimal, illustrative)")
|
| 105 |
width: Optional[int] = Field(1200, description="Image width in px")
|
| 106 |
height: Optional[int] = Field(628, description="Image height in px")
|
|
|
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
class BudgetType(str, Enum):
|
| 111 |
daily = "daily"
|
| 112 |
lifetime = "lifetime"
|
| 113 |
|
| 114 |
-
|
| 115 |
class BudgetPlan(BaseModel):
|
| 116 |
type: BudgetType
|
| 117 |
budget: str
|
| 118 |
duration: str
|
| 119 |
|
| 120 |
-
|
| 121 |
class BudgetRequest(BaseModel):
|
| 122 |
business_name: str = Field(..., example="GrowthAspired")
|
| 123 |
business_category: str = Field(..., example="Software House")
|
|
@@ -129,5 +146,5 @@ class BudgetRequest(BaseModel):
|
|
| 129 |
serving_clients_info: str
|
| 130 |
serving_clients_location: str
|
| 131 |
selected_personas: List[Persona] = Field(..., description="List of selected persona objects to target")
|
| 132 |
-
# optional override if you want more/less options in future
|
| 133 |
num_options: Optional[int] = Field(2, description="Number of budget option groups to return (default 2)")
|
|
|
|
|
|
| 1 |
+
# app/ads/schemas.py
|
| 2 |
from enum import Enum
|
| 3 |
from pydantic import BaseModel, Field
|
| 4 |
from typing import List, Optional
|
|
|
|
| 28 |
obj.description = description
|
| 29 |
return obj
|
| 30 |
|
| 31 |
+
class ToneEnum(str, Enum):
|
| 32 |
+
PROFESSIONAL = "Professional"
|
| 33 |
+
CASUAL_FRIENDLY = "Casual / Friendly"
|
| 34 |
+
BOLD_PERSUASIVE = "Bold / Persuasive"
|
| 35 |
+
INSPIRING_VISIONARY = "Inspiring / Visionary"
|
| 36 |
+
|
| 37 |
class Persona(BaseModel):
|
| 38 |
uuid: str = Field(default_factory=lambda: str(uuid4()), description="Unique identifier for this persona")
|
| 39 |
flag: bool = Field(False, description="Boolean flag for client use (defaults to False)")
|
|
|
|
| 55 |
serving_clients_info: str
|
| 56 |
serving_clients_location: str
|
| 57 |
num_personas: int = 3
|
| 58 |
+
tone: Optional[ToneEnum] = Field(ToneEnum.PROFESSIONAL, description="Tone to use when generating content. Defaults to 'Professional'.")
|
| 59 |
|
| 60 |
class RegenerateRequest(BusinessInput):
|
| 61 |
"""
|
|
|
|
| 65 |
"""
|
| 66 |
previous_personas: List[Persona]
|
| 67 |
|
|
|
|
| 68 |
class HeadingsRequest(BaseModel):
|
| 69 |
business_name: str = Field(..., example="GrowthAspired")
|
| 70 |
business_category: str = Field(..., example="Software House")
|
|
|
|
| 75 |
main_goal: GoalEnum = Field(..., description="Primary marketing goal (enum)")
|
| 76 |
serving_clients_info: str
|
| 77 |
serving_clients_location: str
|
|
|
|
| 78 |
selected_personas: List[Persona] = Field(..., description="List of selected persona objects to target")
|
|
|
|
| 79 |
num_headings: Optional[int] = Field(4, description="How many headings to generate")
|
| 80 |
+
tone: Optional[ToneEnum] = Field(ToneEnum.PROFESSIONAL, description="Tone to use for headings. Defaults to 'Professional'.")
|
| 81 |
|
| 82 |
class DescriptionsRequest(BaseModel):
|
| 83 |
business_name: str = Field(..., example="GrowthAspired")
|
|
|
|
| 91 |
serving_clients_location: str
|
| 92 |
selected_personas: List[Persona] = Field(..., description="List of selected persona objects to target")
|
| 93 |
num_descriptions: Optional[int] = Field(4, description="How many ad descriptions to generate (default 4)")
|
| 94 |
+
tone: Optional[ToneEnum] = Field(ToneEnum.PROFESSIONAL, description="Tone to use for descriptions. Defaults to 'Professional'.")
|
| 95 |
|
| 96 |
class ImageRequest(BaseModel):
|
| 97 |
business_name: str = Field(..., example="GrowthAspired")
|
|
|
|
| 104 |
serving_clients_info: str
|
| 105 |
serving_clients_location: str
|
| 106 |
selected_personas: List[Persona] = Field(..., description="List of selected persona objects to target")
|
|
|
|
| 107 |
style: Optional[str] = Field("modern", description="Desired art style or mood (e.g. modern, minimal, illustrative)")
|
| 108 |
width: Optional[int] = Field(1200, description="Image width in px")
|
| 109 |
height: Optional[int] = Field(628, description="Image height in px")
|
| 110 |
+
tone: Optional[ToneEnum] = Field(ToneEnum.PROFESSIONAL, description="Tone for image copy or captions. Optional.")
|
| 111 |
|
| 112 |
+
# --- NEW FIELDS TO ADD ---
|
| 113 |
+
cta_text: Optional[str] = Field(
|
| 114 |
+
None,
|
| 115 |
+
description="The exact call-to-action text for the banner (e.g., 'Learn More', 'Get Free Call').",
|
| 116 |
+
example="Get Your Free Strategy Call"
|
| 117 |
+
)
|
| 118 |
+
brand_colors: Optional[List[str]] = Field(
|
| 119 |
+
None,
|
| 120 |
+
description="A list of primary brand color hex codes.",
|
| 121 |
+
example=["#0D47A1", "#FFC107"]
|
| 122 |
+
)
|
| 123 |
+
visual_preference: Optional[str] = Field(
|
| 124 |
+
None,
|
| 125 |
+
description="Specific request for the main visual (e.g., 'Show a graph of growth', 'Show a professional consultant').",
|
| 126 |
+
example="Show a graph representing website traffic growth"
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
class BudgetType(str, Enum):
|
| 130 |
daily = "daily"
|
| 131 |
lifetime = "lifetime"
|
| 132 |
|
|
|
|
| 133 |
class BudgetPlan(BaseModel):
|
| 134 |
type: BudgetType
|
| 135 |
budget: str
|
| 136 |
duration: str
|
| 137 |
|
|
|
|
| 138 |
class BudgetRequest(BaseModel):
|
| 139 |
business_name: str = Field(..., example="GrowthAspired")
|
| 140 |
business_category: str = Field(..., example="Software House")
|
|
|
|
| 146 |
serving_clients_info: str
|
| 147 |
serving_clients_location: str
|
| 148 |
selected_personas: List[Persona] = Field(..., description="List of selected persona objects to target")
|
|
|
|
| 149 |
num_options: Optional[int] = Field(2, description="Number of budget option groups to return (default 2)")
|
| 150 |
+
tone: Optional[ToneEnum] = Field(ToneEnum.PROFESSIONAL, description="Tone for budget explanations (optional).")
|
app/main.py
CHANGED
|
@@ -22,7 +22,6 @@ from app.keywords.routes import router as keywords_router
|
|
| 22 |
from app.uiux import routes as uiux_routes
|
| 23 |
from app.mobile_usability import routes as mobile_usability
|
| 24 |
from app.ads.persona_routes import router as persona_router
|
| 25 |
-
from app.ads.budget_routes import router as budget_router
|
| 26 |
|
| 27 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
# Suppress warnings
|
|
@@ -85,7 +84,6 @@ app.include_router(keywords_router)
|
|
| 85 |
app.include_router(uiux_routes.router)
|
| 86 |
app.include_router(mobile_usability.router)
|
| 87 |
app.include_router(persona_router)
|
| 88 |
-
app.include_router(budget_router)
|
| 89 |
|
| 90 |
|
| 91 |
# CORS
|
|
|
|
| 22 |
from app.uiux import routes as uiux_routes
|
| 23 |
from app.mobile_usability import routes as mobile_usability
|
| 24 |
from app.ads.persona_routes import router as persona_router
|
|
|
|
| 25 |
|
| 26 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
# Suppress warnings
|
|
|
|
| 84 |
app.include_router(uiux_routes.router)
|
| 85 |
app.include_router(mobile_usability.router)
|
| 86 |
app.include_router(persona_router)
|
|
|
|
| 87 |
|
| 88 |
|
| 89 |
# CORS
|