Hammad712 commited on
Commit
6f77b72
Β·
1 Parent(s): 3c129ba

Added Pricing Endpoint

Browse files
app/ads/budget_routes.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/budget_service.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/services/budget_service.py
2
+ import os
3
+ import json
4
+ import logging
5
+ import time
6
+ from typing import List
7
+
8
+ import google.generativeai as genai
9
+ from pydantic import ValidationError
10
+
11
+ from app.ads.schemas import BudgetRequest, BudgetPlan, BudgetType, GoalEnum
12
+
13
+ logger = logging.getLogger(__name__)
14
+ logger.addHandler(logging.NullHandler())
15
+
16
+ # Configure Gemini SDK (harmless if configured elsewhere)
17
+ API_KEY = os.getenv("GEMINI_API_KEY")
18
+ if not API_KEY:
19
+ logger.error("GEMINI_API_KEY not set; Gemini budget generation will fail if called without configuration.")
20
+ else:
21
+ try:
22
+ genai.configure(api_key=API_KEY)
23
+ logger.debug("Configured google.generativeai in budget service")
24
+ except Exception as e:
25
+ logger.exception("Failed to configure google.generativeai in budget service: %s", e)
26
+
27
+
28
+ def _extract_json_array(raw: str) -> str:
29
+ """
30
+ Return the first JSON array substring from raw (from '[' to ']').
31
+ Falls back to returning raw string when brackets are not found.
32
+ """
33
+ start = raw.find('[')
34
+ end = raw.rfind(']')
35
+ if start != -1 and end != -1 and end > start:
36
+ return raw[start:end + 1]
37
+ return raw
38
+
39
+
40
+ def _build_budget_prompt(req: BudgetRequest) -> str:
41
+ """
42
+ Build a prompt that asks Gemini to produce TWO conservative, low-cost budget plan objects.
43
+ NOTE: literal JSON braces in the example are escaped ({{ }}) because this is an f-string.
44
+ """
45
+ main_goal_value = req.main_goal.value
46
+ main_goal_desc = getattr(req.main_goal, "description", "")
47
+
48
+ try:
49
+ personas_json = json.dumps([p.dict() for p in req.selected_personas], indent=2)
50
+ except Exception:
51
+ personas_json = json.dumps(req.selected_personas, indent=2)
52
+
53
+ prompt = f"""
54
+ You are an experienced Facebook Ads strategist for B2B SaaS / AI services. Produce EXACTLY TWO budget plan objects as a JSON array and NOTHING ELSE.
55
+
56
+ Output rules (strict):
57
+ - Return ONLY a JSON array with two objects (no explanation, no metadata, no commentary).
58
+ - Objects must appear in this exact order:
59
+ 1) the DAILY plan (type = "daily")
60
+ 2) the LIFETIME plan (type = "lifetime")
61
+ - Each object must contain exactly these keys in this exact order:
62
+ 1. type (string) β€” "daily" or "lifetime"
63
+ 2. budget (string) β€” for "daily" use "$XX/day"; for "lifetime" use "$YYY total"
64
+ 3. duration (string) β€” integer days, formatted like "14 days"
65
+ - Do NOT add, omit or rename keys. Do NOT include numbers with commas (use plain digits).
66
+ - Use whole-dollar amounts unless cents are strictly needed.
67
+
68
+ Business context (use this to pick realistic numbers):
69
+ - Business name: {req.business_name}
70
+ - Category: {req.business_category}
71
+ - Description: {req.business_description}
72
+ - Promotion type: {req.promotion_type}
73
+ - Offer: {req.offer_description}
74
+ - Value: {req.value}
75
+ - Main goal: {main_goal_value} β€” {main_goal_desc}
76
+ - Serving clients: {req.serving_clients_info}
77
+ - Locations: {req.serving_clients_location}
78
+
79
+ Persona context:
80
+ {personas_json}
81
+
82
+ Cost-savings / "less expensive" constraints (MUST follow):
83
+ - This is for a new or low-data business. Prioritize conservative, budget-friendly plans.
84
+ - DAILY plan (short test):
85
+ β€’ Duration: choose between 7 and 14 days.
86
+ β€’ Daily budget: choose between $10/day and $30/day (prefer values at or below $20/day for new accounts).
87
+ - LIFETIME plan (scaling/run):
88
+ β€’ Duration: choose between 15 and 60 days.
89
+ β€’ Lifetime budget: choose between $300 total and $1200 total.
90
+ β€’ Lifetime total must be consistent with a daily-equivalent that does NOT exceed $30/day.
91
+ - Do NOT propose daily budgets above $30/day or lifetime totals above $1200.
92
+ - Prefer round, whole-dollar amounts and conservative choices when in doubt.
93
+
94
+ Formatting example (escaped so this f-string compiles):
95
+ [
96
+ {{ "type":"daily","budget":"$15/day","duration":"10 days" }},
97
+ {{ "type":"lifetime","budget":"$600 total","duration":"30 days" }}
98
+ ]
99
+
100
+ Now generate the two-budget JSON array that strictly follows the rules above.
101
+ """
102
+ logger.debug("Built conservative/low-cost budget prompt for business '%s' (len=%d)", req.business_name, len(prompt))
103
+ return prompt.strip()
104
+
105
+ def generate_budget_plans(req: BudgetRequest) -> List[BudgetPlan]:
106
+ """
107
+ Call Gemini to generate two budget plans, parse and validate them into BudgetPlan objects.
108
+ Returns a list of two BudgetPlan instances.
109
+ """
110
+ prompt = _build_budget_prompt(req)
111
+
112
+ model_name = "gemini-2.5-pro"
113
+ logger.info("Generating budget plans for business '%s' using model %s", req.business_name, model_name)
114
+
115
+ try:
116
+ model = genai.GenerativeModel(model_name)
117
+ except Exception as e:
118
+ logger.exception("Failed to create GenerativeModel: %s", e)
119
+ raise RuntimeError(f"Gemini model init failed: {e}")
120
+
121
+ try:
122
+ start = time.perf_counter()
123
+ response = model.generate_content(prompt)
124
+ duration = time.perf_counter() - start
125
+ logger.info("Gemini generate_content (budgets) completed in %.2fs", duration)
126
+ except Exception as e:
127
+ logger.exception("Gemini generate_content failed for budgets")
128
+ raise RuntimeError(f"Gemini request failed: {e}")
129
+
130
+ # Extract raw text from response
131
+ raw = None
132
+ try:
133
+ if response and hasattr(response, "text") and response.text:
134
+ raw = response.text
135
+ logger.debug("Received response.text (len=%d) for budgets", len(raw))
136
+ elif response and getattr(response, "candidates", None):
137
+ first = response.candidates[0]
138
+ # check safety block
139
+ if getattr(first, "finish_reason", "").upper() == "SAFETY":
140
+ msg = "Gemini budget generation blocked by safety filter"
141
+ logger.error(msg)
142
+ raise RuntimeError(msg)
143
+ # try to find textual content inside candidate
144
+ content = getattr(first, "content", None)
145
+ if content:
146
+ # content.parts may exist
147
+ parts = getattr(content, "parts", None) or []
148
+ texts = []
149
+ for part in parts:
150
+ t = getattr(part, "text", None)
151
+ if t:
152
+ texts.append(t)
153
+ raw = "\n\n".join(texts) if texts else str(first)
154
+ else:
155
+ raw = getattr(first, "text", None) or str(response)
156
+ logger.debug("Received candidate-based response for budgets (len=%d)", len(raw) if raw else 0)
157
+ else:
158
+ raw = str(response)
159
+ logger.debug("Converted budgets response to string (len=%d)", len(raw))
160
+ except Exception as e:
161
+ logger.exception("Failed to extract raw text from Gemini budgets response")
162
+ raise RuntimeError(f"Failed to extract Gemini response text: {e}")
163
+
164
+ if not raw:
165
+ logger.error("Empty response from Gemini when generating budgets")
166
+ raise RuntimeError("Empty response from Gemini")
167
+
168
+ # Robust JSON extraction & parsing
169
+ snippet = _extract_json_array(raw)
170
+ try:
171
+ parsed = json.loads(snippet)
172
+ # If model wrapped array inside object keys, find it
173
+ if isinstance(parsed, dict):
174
+ for key in ("items", "plans", "data", "results"):
175
+ if key in parsed and isinstance(parsed[key], list):
176
+ parsed = parsed[key]
177
+ break
178
+ except json.JSONDecodeError:
179
+ logger.exception("Failed to parse JSON from budgets response. Raw response: %s", raw)
180
+ raise RuntimeError(f"Failed to parse Gemini response as JSON array of budget objects.\nRaw: {raw}")
181
+
182
+ # Validate structure
183
+ if not isinstance(parsed, list):
184
+ logger.error("Parsed budgets JSON is not a list. Parsed type: %s", type(parsed))
185
+ raise RuntimeError("Gemini did not return a JSON array as expected for budgets.")
186
+
187
+ # Optionally trim or require exactly 2 objects
188
+ if len(parsed) < 2:
189
+ logger.warning("Gemini returned fewer than 2 budget objects (%d).", len(parsed))
190
+ # Attempt to convert each into BudgetPlan
191
+ plans: List[BudgetPlan] = []
192
+ for idx, obj in enumerate(parsed[:2]): # only parse up to 2 objects
193
+ try:
194
+ plan = BudgetPlan.parse_obj(obj)
195
+ # Ensure type is one of enums by casting/validating
196
+ if plan.type not in (BudgetType.daily, BudgetType.lifetime):
197
+ logger.debug("Plan %d has non-standard type '%s' β€” attempting to normalize", idx, plan.type)
198
+ # try to normalize common variants
199
+ t_lower = str(plan.type).lower()
200
+ if "daily" in t_lower:
201
+ plan.type = BudgetType.daily
202
+ elif "life" in t_lower or "lifetime" in t_lower:
203
+ plan.type = BudgetType.lifetime
204
+ else:
205
+ # last resort: set daily for first and lifetime for second based on index
206
+ plan.type = BudgetType.daily if idx == 0 else BudgetType.lifetime
207
+ plans.append(plan)
208
+ except ValidationError as ve:
209
+ logger.error("BudgetPlan validation failed for item %s: %s\nRaw item: %s", idx, ve, obj)
210
+ raise RuntimeError(f"Failed to validate budget plan item {idx}: {ve}")
211
+
212
+ if not plans:
213
+ logger.error("No valid budget plans parsed from Gemini response. Raw: %s", raw)
214
+ raise RuntimeError("No valid budget plans parsed from Gemini response.")
215
+
216
+ logger.info("Successfully generated %d budget plan(s) for business '%s'", len(plans), req.business_name)
217
+ return plans
app/ads/schemas.py CHANGED
@@ -1,7 +1,7 @@
1
- # app/ads/schemas.py
2
  from enum import Enum
3
  from pydantic import BaseModel, Field
4
  from typing import List, Optional
 
5
 
6
  class GoalEnum(str, Enum):
7
  GET_MORE_WEBSITE_VISITORS = (
@@ -28,6 +28,8 @@ class GoalEnum(str, Enum):
28
  return obj
29
 
30
  class Persona(BaseModel):
 
 
31
  name: str
32
  headline: str
33
  age_range: str
@@ -102,3 +104,30 @@ class ImageRequest(BaseModel):
102
  style: Optional[str] = Field("modern", description="Desired art style or mood (e.g. modern, minimal, illustrative)")
103
  width: Optional[int] = Field(1200, description="Image width in px")
104
  height: Optional[int] = Field(628, description="Image height in px")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from enum import Enum
2
  from pydantic import BaseModel, Field
3
  from typing import List, Optional
4
+ from uuid import uuid4
5
 
6
  class GoalEnum(str, Enum):
7
  GET_MORE_WEBSITE_VISITORS = (
 
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)")
33
  name: str
34
  headline: str
35
  age_range: str
 
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")
124
+ business_description: str
125
+ promotion_type: str
126
+ offer_description: str
127
+ value: str
128
+ main_goal: GoalEnum = Field(..., description="Primary marketing goal (enum)")
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)")
app/main.py CHANGED
@@ -22,6 +22,8 @@ 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
  # ─────────────────────────────────────────────
26
  # Suppress warnings
27
  # ─────────────────────────────────────────────
@@ -83,6 +85,8 @@ app.include_router(keywords_router)
83
  app.include_router(uiux_routes.router)
84
  app.include_router(mobile_usability.router)
85
  app.include_router(persona_router)
 
 
86
 
87
  # CORS
88
  app.add_middleware(
 
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
29
  # ─────────────────────────────────────────────
 
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
92
  app.add_middleware(