Spaces:
Running
Running
Update main.py
Browse files
main.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade
|
| 3 |
|
| 4 |
-
✅
|
| 5 |
-
✅
|
| 6 |
-
✅
|
| 7 |
-
✅
|
| 8 |
-
✅
|
| 9 |
|
| 10 |
ENV VARS:
|
| 11 |
- GOOGLE_API_KEY=...
|
|
@@ -94,7 +94,7 @@ ZIM_CONTEXT = {
|
|
| 94 |
"fuel_petrol": 1.58,
|
| 95 |
"fuel_diesel": 1.65,
|
| 96 |
"gas_lpg": 2.00,
|
| 97 |
-
"bread_avg": 1.
|
| 98 |
"zesa_step_1": {"limit": 50, "rate": 0.04},
|
| 99 |
"zesa_step_2": {"limit": 150, "rate": 0.09},
|
| 100 |
"zesa_step_3": {"limit": 9999, "rate": 0.14},
|
|
@@ -236,7 +236,7 @@ def get_market_index(force_refresh: bool = False) -> pd.DataFrame:
|
|
| 236 |
return _data_cache["df"]
|
| 237 |
|
| 238 |
# =========================
|
| 239 |
-
# 2. Analyst Engine (Matrix &
|
| 240 |
# =========================
|
| 241 |
|
| 242 |
def search_products_deep(df: pd.DataFrame, query: str, limit: int = 15) -> pd.DataFrame:
|
|
@@ -288,19 +288,16 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
|
|
| 288 |
best_match = hits.iloc[0]
|
| 289 |
|
| 290 |
# --- Brand Fidelity Check ---
|
| 291 |
-
# Did the user ask for "Top Chef" but we got "Mega Basmati"?
|
| 292 |
q_norm = _norm(item)
|
| 293 |
res_norm = _norm(best_match['product_name'] + " " + best_match['brand'])
|
| 294 |
-
|
| 295 |
-
# Simple heuristic: If query has 2+ words, and <50% of them are in result, it's a sub.
|
| 296 |
q_tokens = q_norm.split()
|
| 297 |
is_substitute = False
|
| 298 |
if len(q_tokens) > 1:
|
| 299 |
found_tokens = sum(1 for t in q_tokens if t in res_norm)
|
| 300 |
-
if found_tokens < len(q_tokens) / 2:
|
| 301 |
is_substitute = True
|
| 302 |
|
| 303 |
-
# Aggregate all offers
|
| 304 |
product_offers = hits[hits['product_name'] == best_match['product_name']].sort_values('price')
|
| 305 |
|
| 306 |
offers_list = []
|
|
@@ -310,7 +307,7 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
|
|
| 310 |
found_items.append({
|
| 311 |
"query": item,
|
| 312 |
"product_name": str(best_match['product_name']),
|
| 313 |
-
"is_substitute": is_substitute,
|
| 314 |
"offers": offers_list,
|
| 315 |
"best_price": offers_list[0]['price']
|
| 316 |
})
|
|
@@ -319,7 +316,6 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
|
|
| 319 |
return {"actionable": True, "found_items": [], "global_missing": missing_global}
|
| 320 |
|
| 321 |
# 2. MARKET MATRIX (Comparison across all stores)
|
| 322 |
-
# Get unique retailers involved in these products
|
| 323 |
all_involved_retailers = set()
|
| 324 |
for f in found_items:
|
| 325 |
for o in f['offers']:
|
|
@@ -333,7 +329,6 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
|
|
| 333 |
missing_in_store = []
|
| 334 |
|
| 335 |
for item in found_items:
|
| 336 |
-
# Find price at this retailer
|
| 337 |
price = next((o['price'] for o in item['offers'] if o['retailer'] == retailer), None)
|
| 338 |
if price:
|
| 339 |
total_price += price
|
|
@@ -349,7 +344,6 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
|
|
| 349 |
"missing_items": missing_in_store
|
| 350 |
})
|
| 351 |
|
| 352 |
-
# Sort Matrix: Most Items Found -> Lowest Price
|
| 353 |
store_comparison.sort(key=lambda x: (-x['found_count'], x['total_price']))
|
| 354 |
|
| 355 |
return {
|
|
@@ -357,7 +351,7 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
|
|
| 357 |
"is_basket": len(found_items) > 1,
|
| 358 |
"found_items": found_items,
|
| 359 |
"global_missing": missing_global,
|
| 360 |
-
"market_matrix": store_comparison[:4],
|
| 361 |
"best_store": store_comparison[0] if store_comparison else None,
|
| 362 |
"preferred_retailer": preferred_retailer
|
| 363 |
}
|
|
@@ -365,7 +359,6 @@ def calculate_basket_optimization(item_names: List[str], preferred_retailer: str
|
|
| 365 |
def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
| 366 |
remaining = amount_usd / 1.06
|
| 367 |
units = 0.0
|
| 368 |
-
breakdown = []
|
| 369 |
|
| 370 |
t1 = ZIM_CONTEXT["zesa_step_1"]
|
| 371 |
cost_t1 = t1["limit"] * t1["rate"]
|
|
@@ -373,7 +366,6 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
|
| 373 |
if remaining > cost_t1:
|
| 374 |
units += t1["limit"]
|
| 375 |
remaining -= cost_t1
|
| 376 |
-
breakdown.append(f"First {t1['limit']}u @ ${t1['rate']}")
|
| 377 |
|
| 378 |
t2 = ZIM_CONTEXT["zesa_step_2"]
|
| 379 |
cost_t2 = t2["limit"] * t2["rate"]
|
|
@@ -381,30 +373,19 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
|
| 381 |
if remaining > cost_t2:
|
| 382 |
units += t2["limit"]
|
| 383 |
remaining -= cost_t2
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
t3 = ZIM_CONTEXT["zesa_step_3"]
|
| 387 |
-
bought = remaining / t3["rate"]
|
| 388 |
-
units += bought
|
| 389 |
-
breakdown.append(f"Balance -> {bought:.1f}u @ ${t3['rate']}")
|
| 390 |
else:
|
| 391 |
-
|
| 392 |
-
units += bought
|
| 393 |
-
breakdown.append(f"Balance -> {bought:.1f}u @ ${t2['rate']}")
|
| 394 |
else:
|
| 395 |
-
|
| 396 |
-
units += bought
|
| 397 |
-
breakdown.append(f"All {bought:.1f}u @ ${t1['rate']}")
|
| 398 |
|
| 399 |
return {
|
| 400 |
"amount_usd": float(amount_usd),
|
| 401 |
-
"est_units_kwh": float(round(units, 1))
|
| 402 |
-
"breakdown": breakdown,
|
| 403 |
-
"note": "Includes approx 6% REA levy deduction."
|
| 404 |
}
|
| 405 |
|
| 406 |
# =========================
|
| 407 |
-
# 3. Gemini Helpers
|
| 408 |
# =========================
|
| 409 |
|
| 410 |
def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
|
@@ -417,11 +398,13 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
|
| 417 |
- SHOPPING_BASKET: Looking for prices, products, "cheapest X".
|
| 418 |
- UTILITY_CALC: Electricity/ZESA questions.
|
| 419 |
- STORE_DECISION: "Where should I buy?", "Which store is cheapest?".
|
|
|
|
| 420 |
|
| 421 |
Extract:
|
| 422 |
-
- items: list of products found.
|
| 423 |
- utility_amount: number
|
| 424 |
- store_preference: if a specific store is named (e.g. "at OK Mart").
|
|
|
|
| 425 |
|
| 426 |
JSON Schema:
|
| 427 |
{
|
|
@@ -429,7 +412,8 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
|
| 429 |
"intent": "string",
|
| 430 |
"items": ["string"],
|
| 431 |
"utility_amount": number,
|
| 432 |
-
"store_preference": "string"
|
|
|
|
| 433 |
}
|
| 434 |
"""
|
| 435 |
try:
|
|
@@ -443,6 +427,29 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
|
| 443 |
logger.error(f"Intent Detect Error: {e}")
|
| 444 |
return {"actionable": False, "intent": "CASUAL_CHAT"}
|
| 445 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
|
| 447 |
if not _gemini_client: return {"error": "AI Offline"}
|
| 448 |
|
|
@@ -497,18 +504,15 @@ def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, chat
|
|
| 497 |
|
| 498 |
LOGIC RULES:
|
| 499 |
|
| 500 |
-
1. **BASKET COMPARISON
|
| 501 |
-
- If `market_matrix` has multiple stores,
|
| 502 |
-
-
|
| 503 |
-
- Don't just show the winner. Show the ecosystem.
|
| 504 |
|
| 505 |
-
2. **BRAND
|
| 506 |
-
- If `is_substitute` is TRUE
|
| 507 |
-
"I couldn't find **[Query Brand]** exactly, so I've used **[Found Product]** ($Price) as a placeholder."
|
| 508 |
-
- Be honest about brand mismatches.
|
| 509 |
|
| 510 |
3. **SINGLE ITEMS**:
|
| 511 |
-
- Best price first, then
|
| 512 |
|
| 513 |
4. **CASUAL**:
|
| 514 |
- Reset if user says "Hi".
|
|
@@ -531,12 +535,32 @@ def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
|
|
| 531 |
|
| 532 |
PROMPT = f"""
|
| 533 |
Generate a formatted Markdown Shopping Plan.
|
|
|
|
|
|
|
| 534 |
DATA: {json.dumps(analyst_result, indent=2, default=str)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
SECTIONS:
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
"""
|
| 541 |
try:
|
| 542 |
resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=PROMPT)
|
|
@@ -555,7 +579,7 @@ def health():
|
|
| 555 |
"ok": True,
|
| 556 |
"offers_indexed": len(df),
|
| 557 |
"api_source": PRICE_API_BASE,
|
| 558 |
-
"persona": "Jessica
|
| 559 |
})
|
| 560 |
|
| 561 |
@app.post("/chat")
|
|
@@ -651,35 +675,87 @@ def analyze_image():
|
|
| 651 |
|
| 652 |
@app.post("/api/call-briefing")
|
| 653 |
def call_briefing():
|
| 654 |
-
|
|
|
|
|
|
|
|
|
|
| 655 |
body = request.get_json(silent=True) or {}
|
| 656 |
pid = body.get("profile_id")
|
| 657 |
-
username = body.get("username")
|
|
|
|
| 658 |
if not pid: return jsonify({"ok": False}), 400
|
|
|
|
|
|
|
| 659 |
prof = {}
|
| 660 |
if db:
|
| 661 |
ref = db.collection("pricelyst_profiles").document(pid)
|
| 662 |
doc = ref.get()
|
| 663 |
if doc.exists: prof = doc.to_dict()
|
| 664 |
else: ref.set({"created_at": datetime.now(timezone.utc).isoformat()})
|
| 665 |
-
|
|
|
|
| 666 |
if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
|
|
|
|
|
|
|
| 667 |
df = get_market_index()
|
| 668 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
if not df.empty:
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
|
| 676 |
@app.post("/api/log-call-usage")
|
| 677 |
def log_call_usage():
|
| 678 |
-
|
|
|
|
|
|
|
|
|
|
| 679 |
body = request.get_json(silent=True) or {}
|
| 680 |
pid = body.get("profile_id")
|
| 681 |
transcript = body.get("transcript", "")
|
|
|
|
| 682 |
if not pid: return jsonify({"ok": False}), 400
|
|
|
|
|
|
|
| 683 |
if len(transcript) > 20 and db:
|
| 684 |
try:
|
| 685 |
curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
|
|
@@ -687,27 +763,59 @@ def log_call_usage():
|
|
| 687 |
mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
|
| 688 |
db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
|
| 689 |
except: pass
|
|
|
|
|
|
|
| 690 |
intent_data = gemini_detect_intent(transcript)
|
| 691 |
plan_data = {}
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 695 |
md_content = gemini_generate_4step_plan(transcript, analyst_result)
|
| 696 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
if db:
|
| 698 |
doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
|
| 699 |
plan_data["id"] = doc_ref.id
|
| 700 |
doc_ref.set(plan_data)
|
|
|
|
| 701 |
if db:
|
| 702 |
-
db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({
|
| 703 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
|
| 705 |
@app.get("/api/shopping-plans")
|
| 706 |
def list_plans():
|
| 707 |
pid = request.args.get("profile_id")
|
| 708 |
if not pid or not db: return jsonify({"ok": False}), 400
|
| 709 |
try:
|
| 710 |
-
docs = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans")
|
|
|
|
| 711 |
return jsonify({"ok": True, "plans": [{"id": d.id, **d.to_dict()} for d in docs]})
|
| 712 |
except: return jsonify({"ok": False}), 500
|
| 713 |
|
|
|
|
| 1 |
"""
|
| 2 |
+
main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v3.0)
|
| 3 |
|
| 4 |
+
✅ Feature: "Concept Exploder" (Converts "Plan a Braai" -> Shopping List).
|
| 5 |
+
✅ Feature: "Hybrid Valuation" (Estimates prices for missing items in Plans).
|
| 6 |
+
✅ Feature: "Market Intelligence" (Pre-calculated Voice Context).
|
| 7 |
+
✅ UI Match: Restored v1 Markdown Tables & Creative Tips.
|
| 8 |
+
✅ Core: Deep Vector Search + Market Matrix + Store Preferences.
|
| 9 |
|
| 10 |
ENV VARS:
|
| 11 |
- GOOGLE_API_KEY=...
|
|
|
|
| 94 |
"fuel_petrol": 1.58,
|
| 95 |
"fuel_diesel": 1.65,
|
| 96 |
"gas_lpg": 2.00,
|
| 97 |
+
"bread_avg": 1.10,
|
| 98 |
"zesa_step_1": {"limit": 50, "rate": 0.04},
|
| 99 |
"zesa_step_2": {"limit": 150, "rate": 0.09},
|
| 100 |
"zesa_step_3": {"limit": 9999, "rate": 0.14},
|
|
|
|
| 236 |
return _data_cache["df"]
|
| 237 |
|
| 238 |
# =========================
|
| 239 |
+
# 2. Analyst Engine (Matrix & Calculations)
|
| 240 |
# =========================
|
| 241 |
|
| 242 |
def search_products_deep(df: pd.DataFrame, query: str, limit: int = 15) -> pd.DataFrame:
|
|
|
|
| 288 |
best_match = hits.iloc[0]
|
| 289 |
|
| 290 |
# --- Brand Fidelity Check ---
|
|
|
|
| 291 |
q_norm = _norm(item)
|
| 292 |
res_norm = _norm(best_match['product_name'] + " " + best_match['brand'])
|
|
|
|
|
|
|
| 293 |
q_tokens = q_norm.split()
|
| 294 |
is_substitute = False
|
| 295 |
if len(q_tokens) > 1:
|
| 296 |
found_tokens = sum(1 for t in q_tokens if t in res_norm)
|
| 297 |
+
if found_tokens < len(q_tokens) / 2:
|
| 298 |
is_substitute = True
|
| 299 |
|
| 300 |
+
# Aggregate all offers
|
| 301 |
product_offers = hits[hits['product_name'] == best_match['product_name']].sort_values('price')
|
| 302 |
|
| 303 |
offers_list = []
|
|
|
|
| 307 |
found_items.append({
|
| 308 |
"query": item,
|
| 309 |
"product_name": str(best_match['product_name']),
|
| 310 |
+
"is_substitute": is_substitute,
|
| 311 |
"offers": offers_list,
|
| 312 |
"best_price": offers_list[0]['price']
|
| 313 |
})
|
|
|
|
| 316 |
return {"actionable": True, "found_items": [], "global_missing": missing_global}
|
| 317 |
|
| 318 |
# 2. MARKET MATRIX (Comparison across all stores)
|
|
|
|
| 319 |
all_involved_retailers = set()
|
| 320 |
for f in found_items:
|
| 321 |
for o in f['offers']:
|
|
|
|
| 329 |
missing_in_store = []
|
| 330 |
|
| 331 |
for item in found_items:
|
|
|
|
| 332 |
price = next((o['price'] for o in item['offers'] if o['retailer'] == retailer), None)
|
| 333 |
if price:
|
| 334 |
total_price += price
|
|
|
|
| 344 |
"missing_items": missing_in_store
|
| 345 |
})
|
| 346 |
|
|
|
|
| 347 |
store_comparison.sort(key=lambda x: (-x['found_count'], x['total_price']))
|
| 348 |
|
| 349 |
return {
|
|
|
|
| 351 |
"is_basket": len(found_items) > 1,
|
| 352 |
"found_items": found_items,
|
| 353 |
"global_missing": missing_global,
|
| 354 |
+
"market_matrix": store_comparison[:4],
|
| 355 |
"best_store": store_comparison[0] if store_comparison else None,
|
| 356 |
"preferred_retailer": preferred_retailer
|
| 357 |
}
|
|
|
|
| 359 |
def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
| 360 |
remaining = amount_usd / 1.06
|
| 361 |
units = 0.0
|
|
|
|
| 362 |
|
| 363 |
t1 = ZIM_CONTEXT["zesa_step_1"]
|
| 364 |
cost_t1 = t1["limit"] * t1["rate"]
|
|
|
|
| 366 |
if remaining > cost_t1:
|
| 367 |
units += t1["limit"]
|
| 368 |
remaining -= cost_t1
|
|
|
|
| 369 |
|
| 370 |
t2 = ZIM_CONTEXT["zesa_step_2"]
|
| 371 |
cost_t2 = t2["limit"] * t2["rate"]
|
|
|
|
| 373 |
if remaining > cost_t2:
|
| 374 |
units += t2["limit"]
|
| 375 |
remaining -= cost_t2
|
| 376 |
+
units += remaining / ZIM_CONTEXT["zesa_step_3"]["rate"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
else:
|
| 378 |
+
units += remaining / t2["rate"]
|
|
|
|
|
|
|
| 379 |
else:
|
| 380 |
+
units += remaining / t1["rate"]
|
|
|
|
|
|
|
| 381 |
|
| 382 |
return {
|
| 383 |
"amount_usd": float(amount_usd),
|
| 384 |
+
"est_units_kwh": float(round(units, 1))
|
|
|
|
|
|
|
| 385 |
}
|
| 386 |
|
| 387 |
# =========================
|
| 388 |
+
# 3. Gemini Helpers (The Intelligence)
|
| 389 |
# =========================
|
| 390 |
|
| 391 |
def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
|
|
|
| 398 |
- SHOPPING_BASKET: Looking for prices, products, "cheapest X".
|
| 399 |
- UTILITY_CALC: Electricity/ZESA questions.
|
| 400 |
- STORE_DECISION: "Where should I buy?", "Which store is cheapest?".
|
| 401 |
+
- EVENT_PLANNING: "Plan a braai", "Wedding list", "Dinner for 5" (Implicit lists).
|
| 402 |
|
| 403 |
Extract:
|
| 404 |
+
- items: list of specific products found.
|
| 405 |
- utility_amount: number
|
| 406 |
- store_preference: if a specific store is named (e.g. "at OK Mart").
|
| 407 |
+
- is_event_planning: boolean (true if user asks to plan an event but lists no items).
|
| 408 |
|
| 409 |
JSON Schema:
|
| 410 |
{
|
|
|
|
| 412 |
"intent": "string",
|
| 413 |
"items": ["string"],
|
| 414 |
"utility_amount": number,
|
| 415 |
+
"store_preference": "string",
|
| 416 |
+
"is_event_planning": boolean
|
| 417 |
}
|
| 418 |
"""
|
| 419 |
try:
|
|
|
|
| 427 |
logger.error(f"Intent Detect Error: {e}")
|
| 428 |
return {"actionable": False, "intent": "CASUAL_CHAT"}
|
| 429 |
|
| 430 |
+
def gemini_explode_concept(transcript: str) -> List[str]:
|
| 431 |
+
"""
|
| 432 |
+
Converts a concept ("Braai for 10") into a concrete list ("Wors", "Charcoal").
|
| 433 |
+
"""
|
| 434 |
+
if not _gemini_client: return []
|
| 435 |
+
|
| 436 |
+
PROMPT = f"""
|
| 437 |
+
User wants to plan an event: "{transcript}".
|
| 438 |
+
Generate a STRICT list of 10-15 essential Zimbabwean shopping items for this.
|
| 439 |
+
Use local terms (e.g., 'Boerewors', 'Maize Meal', 'Mazoe', 'Charcoal').
|
| 440 |
+
Return ONLY a JSON list of strings.
|
| 441 |
+
"""
|
| 442 |
+
try:
|
| 443 |
+
resp = _gemini_client.models.generate_content(
|
| 444 |
+
model=GEMINI_MODEL,
|
| 445 |
+
contents=PROMPT,
|
| 446 |
+
config=types.GenerateContentConfig(response_mime_type="application/json")
|
| 447 |
+
)
|
| 448 |
+
return _safe_json_loads(resp.text, [])
|
| 449 |
+
except Exception as e:
|
| 450 |
+
logger.error(f"Explode Concept Error: {e}")
|
| 451 |
+
return []
|
| 452 |
+
|
| 453 |
def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
|
| 454 |
if not _gemini_client: return {"error": "AI Offline"}
|
| 455 |
|
|
|
|
| 504 |
|
| 505 |
LOGIC RULES:
|
| 506 |
|
| 507 |
+
1. **BASKET COMPARISON**:
|
| 508 |
+
- If `market_matrix` has multiple stores, compare totals.
|
| 509 |
+
- "Spar is **$6.95**, OK Mart is **$4.00** (but missing Oil)."
|
|
|
|
| 510 |
|
| 511 |
+
2. **BRAND SUBSTITUTES**:
|
| 512 |
+
- If `is_substitute` is TRUE: "I couldn't find **[Query]**, so I used **[Found]** ($Price) as a placeholder."
|
|
|
|
|
|
|
| 513 |
|
| 514 |
3. **SINGLE ITEMS**:
|
| 515 |
+
- Best price first, then others.
|
| 516 |
|
| 517 |
4. **CASUAL**:
|
| 518 |
- Reset if user says "Hi".
|
|
|
|
| 535 |
|
| 536 |
PROMPT = f"""
|
| 537 |
Generate a formatted Markdown Shopping Plan.
|
| 538 |
+
|
| 539 |
+
USER REQUEST: "{transcript}"
|
| 540 |
DATA: {json.dumps(analyst_result, indent=2, default=str)}
|
| 541 |
+
|
| 542 |
+
CRITICAL INSTRUCTION:
|
| 543 |
+
For items in 'global_missing', you MUST provide a Realistic USD Estimate (e.g. Chicken ~$6.00).
|
| 544 |
+
Do not leave them as "Unknown".
|
| 545 |
+
|
| 546 |
SECTIONS:
|
| 547 |
+
|
| 548 |
+
1. **In Our Catalogue ✅**
|
| 549 |
+
(Markdown Table: | Item | Retailer | Price (USD) |)
|
| 550 |
+
|
| 551 |
+
2. **Not in Catalogue (Estimates) 😔**
|
| 552 |
+
(Markdown Table: | Item | Estimated Price (USD) |)
|
| 553 |
+
*Fill in estimated prices for missing items based on Zimbabwe market knowledge.*
|
| 554 |
+
|
| 555 |
+
3. **Totals 💰**
|
| 556 |
+
- Confirmed Total (Catalogue)
|
| 557 |
+
- Estimated Total (Missing Items)
|
| 558 |
+
- **Grand Total Estimate**
|
| 559 |
+
|
| 560 |
+
4. **Ideas & Tips 💡**
|
| 561 |
+
- 3 Creative ideas based on the specific event/meal (e.g. Braai tips, Cooking hacks).
|
| 562 |
+
|
| 563 |
+
Tone: Warm, Professional, Zimbabwean.
|
| 564 |
"""
|
| 565 |
try:
|
| 566 |
resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=PROMPT)
|
|
|
|
| 579 |
"ok": True,
|
| 580 |
"offers_indexed": len(df),
|
| 581 |
"api_source": PRICE_API_BASE,
|
| 582 |
+
"persona": "Jessica v3.0 (Event Planner)"
|
| 583 |
})
|
| 584 |
|
| 585 |
@app.post("/chat")
|
|
|
|
| 675 |
|
| 676 |
@app.post("/api/call-briefing")
|
| 677 |
def call_briefing():
|
| 678 |
+
"""
|
| 679 |
+
Injects INTELLIGENT Market Data into the Voice Bot's context.
|
| 680 |
+
Includes: Staples Index, ZESA/Fuel, Top 60 Catalogue.
|
| 681 |
+
"""
|
| 682 |
body = request.get_json(silent=True) or {}
|
| 683 |
pid = body.get("profile_id")
|
| 684 |
+
username = body.get("username", "Friend")
|
| 685 |
+
|
| 686 |
if not pid: return jsonify({"ok": False}), 400
|
| 687 |
+
|
| 688 |
+
# 1. Memory Profile
|
| 689 |
prof = {}
|
| 690 |
if db:
|
| 691 |
ref = db.collection("pricelyst_profiles").document(pid)
|
| 692 |
doc = ref.get()
|
| 693 |
if doc.exists: prof = doc.to_dict()
|
| 694 |
else: ref.set({"created_at": datetime.now(timezone.utc).isoformat()})
|
| 695 |
+
|
| 696 |
+
if username != "Friend" and username != prof.get("username"):
|
| 697 |
if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
|
| 698 |
+
|
| 699 |
+
# 2. Market Intelligence Generation
|
| 700 |
df = get_market_index()
|
| 701 |
+
market_intel = ""
|
| 702 |
+
|
| 703 |
+
# A. ZESA & Fuel
|
| 704 |
+
zesa_10 = calculate_zesa_units(10.0)
|
| 705 |
+
zesa_20 = calculate_zesa_units(20.0)
|
| 706 |
+
|
| 707 |
+
context_section = f"""
|
| 708 |
+
[CRITICAL CONTEXT - ZIMBABWE]
|
| 709 |
+
FUEL: Petrol=${ZIM_CONTEXT['fuel_petrol']}, Diesel=${ZIM_CONTEXT['fuel_diesel']}
|
| 710 |
+
BREAD: ~${ZIM_CONTEXT['bread_avg']}
|
| 711 |
+
ZESA (Electricity): $10 = {zesa_10['est_units_kwh']}u, $20 = {zesa_20['est_units_kwh']}u
|
| 712 |
+
"""
|
| 713 |
+
|
| 714 |
+
# B. Staples Index
|
| 715 |
+
staples = ["Cooking Oil", "Maize Meal", "Sugar", "Rice"]
|
| 716 |
+
staple_summary = []
|
| 717 |
+
|
| 718 |
+
if not df.empty:
|
| 719 |
+
for s in staples:
|
| 720 |
+
hits = search_products_deep(df[df['is_offer']==True], s, limit=5)
|
| 721 |
+
if not hits.empty:
|
| 722 |
+
cheapest = hits.sort_values('price').iloc[0]
|
| 723 |
+
staple_summary.append(f"- {s}: ${cheapest['price']} @ {cheapest['retailer']}")
|
| 724 |
+
|
| 725 |
+
staples_section = "\n[STAPLES - LOWEST]\n" + "\n".join(staple_summary)
|
| 726 |
+
|
| 727 |
+
# C. Top 60 Catalogue
|
| 728 |
+
catalogue_lines = []
|
| 729 |
if not df.empty:
|
| 730 |
+
top_items = df[df['is_offer']==True].sort_values('views', ascending=False).drop_duplicates('product_name').head(60)
|
| 731 |
+
for _, r in top_items.iterrows():
|
| 732 |
+
p_name = r['product_name']
|
| 733 |
+
all_offers = df[(df['product_name'] == p_name) & df['is_offer']]
|
| 734 |
+
prices_str = ", ".join([f"${o['price']} ({o['retailer']})" for _, o in all_offers.iterrows()])
|
| 735 |
+
catalogue_lines.append(f"- {p_name}: {prices_str}")
|
| 736 |
+
|
| 737 |
+
catalogue_section = "\n[CATALOGUE - TOP 60]\n" + "\n".join(catalogue_lines)
|
| 738 |
+
|
| 739 |
+
return jsonify({
|
| 740 |
+
"ok": True,
|
| 741 |
+
"username": username,
|
| 742 |
+
"memory_summary": prof.get("memory_summary", ""),
|
| 743 |
+
"kpi_snapshot": context_section + staples_section + catalogue_section
|
| 744 |
+
})
|
| 745 |
|
| 746 |
@app.post("/api/log-call-usage")
|
| 747 |
def log_call_usage():
|
| 748 |
+
"""
|
| 749 |
+
Post-Call Orchestrator.
|
| 750 |
+
v3.0 Upgrade: Handles Concept Explosion for Event Planning.
|
| 751 |
+
"""
|
| 752 |
body = request.get_json(silent=True) or {}
|
| 753 |
pid = body.get("profile_id")
|
| 754 |
transcript = body.get("transcript", "")
|
| 755 |
+
|
| 756 |
if not pid: return jsonify({"ok": False}), 400
|
| 757 |
+
|
| 758 |
+
# 1. Update Long-Term Memory
|
| 759 |
if len(transcript) > 20 and db:
|
| 760 |
try:
|
| 761 |
curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
|
|
|
|
| 763 |
mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
|
| 764 |
db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
|
| 765 |
except: pass
|
| 766 |
+
|
| 767 |
+
# 2. Plan Generation Logic
|
| 768 |
intent_data = gemini_detect_intent(transcript)
|
| 769 |
plan_data = {}
|
| 770 |
+
|
| 771 |
+
# Check if ACTIONABLE (Shopping or Event)
|
| 772 |
+
if intent_data.get("actionable"):
|
| 773 |
+
target_items = intent_data.get("items", [])
|
| 774 |
+
|
| 775 |
+
# LOGIC: If Event Planning + No specific items -> EXPLODE CONCEPT
|
| 776 |
+
if intent_data.get("is_event_planning") and not target_items:
|
| 777 |
+
logger.info("💥 Exploding Concept for Event...")
|
| 778 |
+
target_items = gemini_explode_concept(transcript)
|
| 779 |
+
|
| 780 |
+
if target_items:
|
| 781 |
+
analyst_result = calculate_basket_optimization(target_items)
|
| 782 |
+
|
| 783 |
+
# v3.0: Even if missing items, we generate plan because prompt will ESTIMATE them
|
| 784 |
md_content = gemini_generate_4step_plan(transcript, analyst_result)
|
| 785 |
+
|
| 786 |
+
plan_data = {
|
| 787 |
+
"is_actionable": True,
|
| 788 |
+
"title": f"Plan ({datetime.now().strftime('%d %b')})",
|
| 789 |
+
"markdown_content": md_content,
|
| 790 |
+
"items": target_items,
|
| 791 |
+
"created_at": datetime.now(timezone.utc).isoformat()
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
if db:
|
| 795 |
doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
|
| 796 |
plan_data["id"] = doc_ref.id
|
| 797 |
doc_ref.set(plan_data)
|
| 798 |
+
|
| 799 |
if db:
|
| 800 |
+
db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({
|
| 801 |
+
"transcript": transcript,
|
| 802 |
+
"intent": intent_data,
|
| 803 |
+
"plan_generated": bool(plan_data),
|
| 804 |
+
"ts": datetime.now(timezone.utc).isoformat()
|
| 805 |
+
})
|
| 806 |
+
|
| 807 |
+
return jsonify({
|
| 808 |
+
"ok": True,
|
| 809 |
+
"shopping_plan": plan_data if plan_data.get("is_actionable") else None
|
| 810 |
+
})
|
| 811 |
|
| 812 |
@app.get("/api/shopping-plans")
|
| 813 |
def list_plans():
|
| 814 |
pid = request.args.get("profile_id")
|
| 815 |
if not pid or not db: return jsonify({"ok": False}), 400
|
| 816 |
try:
|
| 817 |
+
docs = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans") \
|
| 818 |
+
.order_by("created_at", direction=firestore.Query.DESCENDING).limit(10).stream()
|
| 819 |
return jsonify({"ok": True, "plans": [{"id": d.id, **d.to_dict()} for d in docs]})
|
| 820 |
except: return jsonify({"ok": False}), 500
|
| 821 |
|