Spaces:
Running
Running
Update main.py
Browse files
main.py
CHANGED
|
@@ -1,19 +1,18 @@
|
|
| 1 |
"""
|
| 2 |
-
main.py — Pricelyst Shopping Advisor (
|
| 3 |
|
| 4 |
✅ Flask API
|
| 5 |
✅ Firebase Admin Persistence
|
| 6 |
-
✅ Gemini
|
| 7 |
-
✅ "Analyst Engine": Python Math for Baskets
|
| 8 |
-
✅
|
| 9 |
-
✅ Jessica Context:
|
| 10 |
-
✅ Intent Detection: Strict Casual vs Actionable separation
|
| 11 |
|
| 12 |
ENV VARS:
|
| 13 |
- GOOGLE_API_KEY=...
|
| 14 |
- FIREBASE='{"type":"service_account", ...}'
|
| 15 |
- PRICE_API_BASE=https://api.pricelyst.co.zw
|
| 16 |
-
- GEMINI_MODEL=gemini-2.
|
| 17 |
- PORT=5000
|
| 18 |
"""
|
| 19 |
|
|
@@ -49,7 +48,8 @@ except Exception as e:
|
|
| 49 |
logger.error("google-genai not installed. pip install google-genai. Error=%s", e)
|
| 50 |
|
| 51 |
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "")
|
| 52 |
-
|
|
|
|
| 53 |
|
| 54 |
_gemini_client = None
|
| 55 |
if genai and GOOGLE_API_KEY:
|
|
@@ -165,7 +165,6 @@ def fetch_and_flatten_data() -> pd.DataFrame:
|
|
| 165 |
logger.error(f"ETL Error on page {page}: {e}")
|
| 166 |
break
|
| 167 |
|
| 168 |
-
# Flattening Logic
|
| 169 |
rows = []
|
| 170 |
for p in all_products:
|
| 171 |
try:
|
|
@@ -240,14 +239,13 @@ def get_market_index(force_refresh: bool = False) -> pd.DataFrame:
|
|
| 240 |
|
| 241 |
def search_products_fuzzy(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFrame:
|
| 242 |
if df.empty or not query: return df
|
| 243 |
-
|
| 244 |
q_norm = _norm(query)
|
| 245 |
|
| 246 |
-
# 1.
|
| 247 |
mask_name = df['clean_name'].str.contains(q_norm, regex=False)
|
| 248 |
matches = df[mask_name].copy()
|
| 249 |
|
| 250 |
-
# 2.
|
| 251 |
if matches.empty:
|
| 252 |
q_tokens = set(q_norm.split())
|
| 253 |
def token_score(text):
|
|
@@ -261,10 +259,9 @@ def search_products_fuzzy(df: pd.DataFrame, query: str, limit: int = 10) -> pd.D
|
|
| 261 |
df_scored['score'] = df_scored['clean_name'].apply(token_score)
|
| 262 |
matches = df_scored[df_scored['score'] > 0]
|
| 263 |
|
| 264 |
-
if matches.empty:
|
| 265 |
-
return matches
|
| 266 |
|
| 267 |
-
# 3.
|
| 268 |
matches = matches.sort_values(by=['views', 'price'], ascending=[False, True])
|
| 269 |
return matches.head(limit)
|
| 270 |
|
|
@@ -277,7 +274,7 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
|
| 277 |
logger.warning("Basket Engine: DF is empty.")
|
| 278 |
return {"actionable": False, "error": "No data"}
|
| 279 |
|
| 280 |
-
logger.info(f"Basket Engine: Optimizing for
|
| 281 |
|
| 282 |
found_items = []
|
| 283 |
missing_global = []
|
|
@@ -289,7 +286,7 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
|
| 289 |
missing_global.append(item)
|
| 290 |
continue
|
| 291 |
|
| 292 |
-
#
|
| 293 |
best_prod = hits.iloc[0]
|
| 294 |
found_items.append({
|
| 295 |
"query": item,
|
|
@@ -297,11 +294,16 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
|
| 297 |
"name": best_prod['product_name']
|
| 298 |
})
|
| 299 |
|
|
|
|
| 300 |
if not found_items:
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
target_pids = [x['product_id'] for x in found_items]
|
| 306 |
relevant_offers = df[df['product_id'].isin(target_pids) & df['is_offer']]
|
| 307 |
|
|
@@ -310,11 +312,9 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
|
| 310 |
|
| 311 |
for retailer in all_retailers:
|
| 312 |
r_df = relevant_offers[relevant_offers['retailer'] == retailer]
|
| 313 |
-
|
| 314 |
found_count = len(r_df)
|
| 315 |
total_price = r_df['price'].sum()
|
| 316 |
|
| 317 |
-
# Identify misses
|
| 318 |
retailer_pids = r_df['product_id'].tolist()
|
| 319 |
found_names = [x['name'] for x in found_items if x['product_id'] in retailer_pids]
|
| 320 |
|
|
@@ -326,25 +326,20 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
|
| 326 |
"found_items": found_names
|
| 327 |
})
|
| 328 |
|
| 329 |
-
# 3. Sort: Coverage Desc, Price Asc
|
| 330 |
retailer_stats.sort(key=lambda x: (-x['coverage_percent'], x['total_price']))
|
| 331 |
-
|
| 332 |
-
if not retailer_stats:
|
| 333 |
-
return {"actionable": False}
|
| 334 |
-
|
| 335 |
-
best_option = retailer_stats[0]
|
| 336 |
-
logger.info(f"Basket Engine: Best Store = {best_option['retailer']} (${best_option['total_price']})")
|
| 337 |
|
| 338 |
return {
|
| 339 |
"actionable": True,
|
| 340 |
"basket_items": [x['name'] for x in found_items],
|
|
|
|
| 341 |
"global_missing": missing_global,
|
| 342 |
"best_store": best_option,
|
| 343 |
"all_stores": retailer_stats[:3]
|
| 344 |
}
|
| 345 |
|
| 346 |
def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
| 347 |
-
remaining = amount_usd / 1.06
|
| 348 |
units = 0.0
|
| 349 |
breakdown = []
|
| 350 |
|
|
@@ -367,7 +362,7 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
|
| 367 |
t3 = ZIM_UTILITIES["zesa_step_3"]
|
| 368 |
bought = remaining / t3["rate"]
|
| 369 |
units += bought
|
| 370 |
-
breakdown.append(f"Balance
|
| 371 |
else:
|
| 372 |
bought = remaining / t2["rate"]
|
| 373 |
units += bought
|
|
@@ -384,28 +379,26 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
|
| 384 |
}
|
| 385 |
|
| 386 |
# =========================
|
| 387 |
-
# 3. Gemini Helpers (
|
| 388 |
# =========================
|
| 389 |
|
| 390 |
def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
| 391 |
-
"""
|
| 392 |
-
Classifies if the conversation needs an Analyst action.
|
| 393 |
-
"""
|
| 394 |
if not _gemini_client: return {"actionable": False}
|
| 395 |
|
| 396 |
PROMPT = """
|
| 397 |
-
Analyze
|
| 398 |
-
Is
|
|
|
|
| 399 |
|
| 400 |
-
|
| 401 |
{
|
| 402 |
"actionable": boolean,
|
| 403 |
"intent": "SHOPPING_BASKET" | "UTILITY_CALC" | "PRODUCT_SEARCH" | "CASUAL_CHAT",
|
| 404 |
-
"items": ["
|
| 405 |
-
"utility_amount": number
|
| 406 |
}
|
| 407 |
"""
|
| 408 |
-
|
| 409 |
try:
|
| 410 |
resp = _gemini_client.models.generate_content(
|
| 411 |
model=GEMINI_MODEL,
|
|
@@ -417,29 +410,51 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
|
| 417 |
logger.error(f"Intent Detect Error: {e}")
|
| 418 |
return {"actionable": False, "intent": "CASUAL_CHAT"}
|
| 419 |
|
| 420 |
-
def
|
| 421 |
-
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
| 423 |
PROMPT = f"""
|
| 424 |
-
You are Jessica, Pricelyst
|
| 425 |
-
|
| 426 |
|
| 427 |
-
|
| 428 |
-
{json.dumps(analyst_data, indent=2)}
|
| 429 |
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
try:
|
| 436 |
resp = _gemini_client.models.generate_content(
|
| 437 |
model=GEMINI_MODEL,
|
| 438 |
contents=PROMPT
|
| 439 |
)
|
| 440 |
return resp.text
|
| 441 |
-
except:
|
| 442 |
-
|
|
|
|
| 443 |
|
| 444 |
# =========================
|
| 445 |
# 4. Endpoints
|
|
@@ -456,35 +471,39 @@ def health():
|
|
| 456 |
|
| 457 |
@app.post("/chat")
|
| 458 |
def chat():
|
| 459 |
-
"""Text Chat
|
| 460 |
body = request.get_json(silent=True) or {}
|
| 461 |
msg = body.get("message", "")
|
| 462 |
pid = body.get("profile_id")
|
| 463 |
|
| 464 |
if not pid: return jsonify({"ok": False}), 400
|
| 465 |
|
| 466 |
-
# 1. Detect Intent
|
| 467 |
intent_data = gemini_detect_intent(msg)
|
| 468 |
analyst_data = {}
|
| 469 |
|
| 470 |
-
# 2. Run Analyst (if actionable)
|
| 471 |
if intent_data.get("actionable"):
|
| 472 |
if intent_data["intent"] == "SHOPPING_BASKET" and intent_data.get("items"):
|
| 473 |
analyst_data = calculate_basket_optimization(intent_data["items"])
|
| 474 |
elif intent_data["intent"] == "UTILITY_CALC":
|
| 475 |
analyst_data = calculate_zesa_units(intent_data.get("utility_amount", 20))
|
| 476 |
elif intent_data["intent"] == "PRODUCT_SEARCH" and intent_data.get("items"):
|
| 477 |
-
# Reuse basket logic for single item search to get best store
|
| 478 |
analyst_data = calculate_basket_optimization(intent_data["items"])
|
| 479 |
|
| 480 |
-
#
|
| 481 |
-
reply =
|
| 482 |
-
|
| 483 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
if db:
|
| 485 |
db.collection("pricelyst_profiles").document(pid).collection("chat_logs").add({
|
| 486 |
"message": msg,
|
| 487 |
-
"
|
| 488 |
"intent": intent_data,
|
| 489 |
"ts": datetime.now(timezone.utc).isoformat()
|
| 490 |
})
|
|
@@ -494,7 +513,7 @@ def chat():
|
|
| 494 |
@app.post("/api/call-briefing")
|
| 495 |
def call_briefing():
|
| 496 |
"""
|
| 497 |
-
Injects Memory + Top
|
| 498 |
"""
|
| 499 |
body = request.get_json(silent=True) or {}
|
| 500 |
pid = body.get("profile_id")
|
|
@@ -510,26 +529,24 @@ def call_briefing():
|
|
| 510 |
prof = doc.to_dict()
|
| 511 |
else:
|
| 512 |
ref.set({"created_at": datetime.now(timezone.utc).isoformat()})
|
| 513 |
-
|
| 514 |
if username and username != prof.get("username"):
|
| 515 |
if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
|
| 516 |
|
| 517 |
-
# ---
|
| 518 |
df = get_market_index()
|
| 519 |
-
|
| 520 |
if not df.empty:
|
| 521 |
-
#
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
# Payload for ElevenLabs (Data Variables Only)
|
| 530 |
kpi_snapshot = {
|
| 531 |
"market_rates": ZIM_UTILITIES,
|
| 532 |
-
"
|
| 533 |
}
|
| 534 |
|
| 535 |
return jsonify({
|
|
@@ -541,10 +558,12 @@ def call_briefing():
|
|
| 541 |
@app.post("/api/log-call-usage")
|
| 542 |
def log_call_usage():
|
| 543 |
"""
|
| 544 |
-
Post-Call
|
| 545 |
-
1.
|
| 546 |
-
2.
|
| 547 |
-
3.
|
|
|
|
|
|
|
| 548 |
"""
|
| 549 |
body = request.get_json(silent=True) or {}
|
| 550 |
pid = body.get("profile_id")
|
|
@@ -554,68 +573,53 @@ def log_call_usage():
|
|
| 554 |
|
| 555 |
logger.info(f"Log Call: Processing {pid}. Transcript Len: {len(transcript)}")
|
| 556 |
|
| 557 |
-
# 1. Update Memory
|
| 558 |
if len(transcript) > 20 and db:
|
| 559 |
try:
|
| 560 |
curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
|
| 561 |
-
mem_prompt = f"Update user memory
|
| 562 |
mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
|
| 563 |
db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
|
| 564 |
except Exception as e:
|
| 565 |
-
logger.error(f"Memory Update
|
| 566 |
|
| 567 |
-
# 2. Intent Detection
|
| 568 |
intent_data = gemini_detect_intent(transcript)
|
| 569 |
-
logger.info(f"
|
| 570 |
|
| 571 |
plan_data = {}
|
| 572 |
|
| 573 |
# 3. Actionable Logic
|
| 574 |
if intent_data.get("actionable"):
|
| 575 |
|
| 576 |
-
#
|
| 577 |
if intent_data.get("items"):
|
| 578 |
analyst_result = calculate_basket_optimization(intent_data["items"])
|
| 579 |
|
|
|
|
|
|
|
| 580 |
if analyst_result.get("actionable"):
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
# Markdown Generation
|
| 584 |
-
md = f"# Shopping Plan\n\n"
|
| 585 |
-
md += f"**Recommended Store:** {best['retailer']}\n"
|
| 586 |
-
md += f"**Estimated Total:** ${best['total_price']:.2f}\n\n"
|
| 587 |
-
|
| 588 |
-
md += "## Your Basket\n\n"
|
| 589 |
-
md += "| Item | Found? |\n|---|---|\n"
|
| 590 |
-
for it in analyst_result["basket_items"]:
|
| 591 |
-
status = "✅ In Stock" if it in best["found_items"] else "❌ Not Found"
|
| 592 |
-
md += f"| {it} | {status} |\n"
|
| 593 |
-
|
| 594 |
-
if analyst_result["global_missing"]:
|
| 595 |
-
md += "\n### Missing Items (Estimate Required)\n"
|
| 596 |
-
for m in analyst_result["global_missing"]:
|
| 597 |
-
md += f"- {m}\n"
|
| 598 |
|
| 599 |
plan_data = {
|
| 600 |
"is_actionable": True,
|
| 601 |
-
"title": f"Plan
|
| 602 |
-
"markdown_content":
|
| 603 |
"items": intent_data["items"],
|
| 604 |
"created_at": datetime.now(timezone.utc).isoformat()
|
| 605 |
}
|
| 606 |
|
| 607 |
-
# Persist Plan
|
| 608 |
if db:
|
| 609 |
doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
|
| 610 |
plan_data["id"] = doc_ref.id
|
| 611 |
doc_ref.set(plan_data)
|
| 612 |
-
logger.info(f"
|
| 613 |
|
| 614 |
-
#
|
| 615 |
if db:
|
| 616 |
db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({
|
| 617 |
"transcript": transcript,
|
| 618 |
-
"
|
| 619 |
"plan_generated": bool(plan_data),
|
| 620 |
"ts": datetime.now(timezone.utc).isoformat()
|
| 621 |
})
|
|
@@ -625,7 +629,7 @@ def log_call_usage():
|
|
| 625 |
"shopping_plan": plan_data if plan_data.get("is_actionable") else None
|
| 626 |
})
|
| 627 |
|
| 628 |
-
# ––––– CRUD
|
| 629 |
|
| 630 |
@app.get("/api/shopping-plans")
|
| 631 |
def list_plans():
|
|
@@ -655,7 +659,6 @@ def delete_plan(plan_id):
|
|
| 655 |
|
| 656 |
if __name__ == "__main__":
|
| 657 |
port = int(os.environ.get("PORT", 7860))
|
| 658 |
-
# Pre-warm Cache
|
| 659 |
try:
|
| 660 |
get_market_index(force_refresh=True)
|
| 661 |
except:
|
|
|
|
| 1 |
"""
|
| 2 |
+
main.py — Pricelyst Shopping Advisor (Jessica 2026 Edition)
|
| 3 |
|
| 4 |
✅ Flask API
|
| 5 |
✅ Firebase Admin Persistence
|
| 6 |
+
✅ Gemini 2.5 Flash (Target Model)
|
| 7 |
+
✅ "Analyst Engine": Python Math for Baskets & ZESA
|
| 8 |
+
✅ "Creative Engine": 4-Step Hybrid Plans (Catalogue -> Estimates -> Totals -> Context Ideas)
|
| 9 |
+
✅ Jessica Context: Live Mini-Catalogue Injection
|
|
|
|
| 10 |
|
| 11 |
ENV VARS:
|
| 12 |
- GOOGLE_API_KEY=...
|
| 13 |
- FIREBASE='{"type":"service_account", ...}'
|
| 14 |
- PRICE_API_BASE=https://api.pricelyst.co.zw
|
| 15 |
+
- GEMINI_MODEL=gemini-2.5-flash
|
| 16 |
- PORT=5000
|
| 17 |
"""
|
| 18 |
|
|
|
|
| 48 |
logger.error("google-genai not installed. pip install google-genai. Error=%s", e)
|
| 49 |
|
| 50 |
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "")
|
| 51 |
+
# Updated to 2.5 Flash as requested for 2026 timeline
|
| 52 |
+
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
| 53 |
|
| 54 |
_gemini_client = None
|
| 55 |
if genai and GOOGLE_API_KEY:
|
|
|
|
| 165 |
logger.error(f"ETL Error on page {page}: {e}")
|
| 166 |
break
|
| 167 |
|
|
|
|
| 168 |
rows = []
|
| 169 |
for p in all_products:
|
| 170 |
try:
|
|
|
|
| 239 |
|
| 240 |
def search_products_fuzzy(df: pd.DataFrame, query: str, limit: int = 10) -> pd.DataFrame:
|
| 241 |
if df.empty or not query: return df
|
|
|
|
| 242 |
q_norm = _norm(query)
|
| 243 |
|
| 244 |
+
# 1. Contains
|
| 245 |
mask_name = df['clean_name'].str.contains(q_norm, regex=False)
|
| 246 |
matches = df[mask_name].copy()
|
| 247 |
|
| 248 |
+
# 2. Token overlap fallback
|
| 249 |
if matches.empty:
|
| 250 |
q_tokens = set(q_norm.split())
|
| 251 |
def token_score(text):
|
|
|
|
| 259 |
df_scored['score'] = df_scored['clean_name'].apply(token_score)
|
| 260 |
matches = df_scored[df_scored['score'] > 0]
|
| 261 |
|
| 262 |
+
if matches.empty: return matches
|
|
|
|
| 263 |
|
| 264 |
+
# 3. Sort by Views (Popularity) + Price
|
| 265 |
matches = matches.sort_values(by=['views', 'price'], ascending=[False, True])
|
| 266 |
return matches.head(limit)
|
| 267 |
|
|
|
|
| 274 |
logger.warning("Basket Engine: DF is empty.")
|
| 275 |
return {"actionable": False, "error": "No data"}
|
| 276 |
|
| 277 |
+
logger.info(f"Basket Engine: Optimizing for: {item_names}")
|
| 278 |
|
| 279 |
found_items = []
|
| 280 |
missing_global = []
|
|
|
|
| 286 |
missing_global.append(item)
|
| 287 |
continue
|
| 288 |
|
| 289 |
+
# Best match based on popularity
|
| 290 |
best_prod = hits.iloc[0]
|
| 291 |
found_items.append({
|
| 292 |
"query": item,
|
|
|
|
| 294 |
"name": best_prod['product_name']
|
| 295 |
})
|
| 296 |
|
| 297 |
+
# Note: We return actionable=True even if products missing, so Gemini can estimate
|
| 298 |
if not found_items:
|
| 299 |
+
return {
|
| 300 |
+
"actionable": True,
|
| 301 |
+
"basket_items": [],
|
| 302 |
+
"global_missing": missing_global,
|
| 303 |
+
"best_store": None
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
# 2. Calculate Retailer Totals
|
| 307 |
target_pids = [x['product_id'] for x in found_items]
|
| 308 |
relevant_offers = df[df['product_id'].isin(target_pids) & df['is_offer']]
|
| 309 |
|
|
|
|
| 312 |
|
| 313 |
for retailer in all_retailers:
|
| 314 |
r_df = relevant_offers[relevant_offers['retailer'] == retailer]
|
|
|
|
| 315 |
found_count = len(r_df)
|
| 316 |
total_price = r_df['price'].sum()
|
| 317 |
|
|
|
|
| 318 |
retailer_pids = r_df['product_id'].tolist()
|
| 319 |
found_names = [x['name'] for x in found_items if x['product_id'] in retailer_pids]
|
| 320 |
|
|
|
|
| 326 |
"found_items": found_names
|
| 327 |
})
|
| 328 |
|
|
|
|
| 329 |
retailer_stats.sort(key=lambda x: (-x['coverage_percent'], x['total_price']))
|
| 330 |
+
best_option = retailer_stats[0] if retailer_stats else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
|
| 332 |
return {
|
| 333 |
"actionable": True,
|
| 334 |
"basket_items": [x['name'] for x in found_items],
|
| 335 |
+
"found_items_details": found_items,
|
| 336 |
"global_missing": missing_global,
|
| 337 |
"best_store": best_option,
|
| 338 |
"all_stores": retailer_stats[:3]
|
| 339 |
}
|
| 340 |
|
| 341 |
def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
| 342 |
+
remaining = amount_usd / 1.06
|
| 343 |
units = 0.0
|
| 344 |
breakdown = []
|
| 345 |
|
|
|
|
| 362 |
t3 = ZIM_UTILITIES["zesa_step_3"]
|
| 363 |
bought = remaining / t3["rate"]
|
| 364 |
units += bought
|
| 365 |
+
breakdown.append(f"Balance -> {bought:.1f}u @ ${t3['rate']}")
|
| 366 |
else:
|
| 367 |
bought = remaining / t2["rate"]
|
| 368 |
units += bought
|
|
|
|
| 379 |
}
|
| 380 |
|
| 381 |
# =========================
|
| 382 |
+
# 3. Gemini Helpers (Detect + Creative)
|
| 383 |
# =========================
|
| 384 |
|
| 385 |
def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
| 386 |
+
"""Strict Intent Classification."""
|
|
|
|
|
|
|
| 387 |
if not _gemini_client: return {"actionable": False}
|
| 388 |
|
| 389 |
PROMPT = """
|
| 390 |
+
Analyze transcript. Return STRICT JSON.
|
| 391 |
+
Is user asking for shopping/prices/basket/utility help? (Actionable)
|
| 392 |
+
Or just saying hello/thanks? (Casual)
|
| 393 |
|
| 394 |
+
Schema:
|
| 395 |
{
|
| 396 |
"actionable": boolean,
|
| 397 |
"intent": "SHOPPING_BASKET" | "UTILITY_CALC" | "PRODUCT_SEARCH" | "CASUAL_CHAT",
|
| 398 |
+
"items": ["list", "of", "items"],
|
| 399 |
+
"utility_amount": number
|
| 400 |
}
|
| 401 |
"""
|
|
|
|
| 402 |
try:
|
| 403 |
resp = _gemini_client.models.generate_content(
|
| 404 |
model=GEMINI_MODEL,
|
|
|
|
| 410 |
logger.error(f"Intent Detect Error: {e}")
|
| 411 |
return {"actionable": False, "intent": "CASUAL_CHAT"}
|
| 412 |
|
| 413 |
+
def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
|
| 414 |
+
"""
|
| 415 |
+
The Creative Engine: Generates the 4-Section Plan.
|
| 416 |
+
"""
|
| 417 |
+
if not _gemini_client: return "# Error\nAI Offline."
|
| 418 |
+
|
| 419 |
PROMPT = f"""
|
| 420 |
+
You are Jessica, Pricelyst's Shopping Advisor.
|
| 421 |
+
Generate a formatted Markdown Shopping Plan.
|
| 422 |
|
| 423 |
+
USER CONTEXT: "{transcript}"
|
|
|
|
| 424 |
|
| 425 |
+
ANALYST DATA (REAL PRICES):
|
| 426 |
+
{json.dumps(analyst_result, indent=2)}
|
| 427 |
+
|
| 428 |
+
REQUIRED SECTIONS (Do not deviate):
|
| 429 |
|
| 430 |
+
1. **In Our Catalogue ✅**
|
| 431 |
+
- List items found in 'found_items_details'.
|
| 432 |
+
- Show product name, retailer, and exact price from data.
|
| 433 |
+
|
| 434 |
+
2. **Not in Catalogue (Estimates) 😔**
|
| 435 |
+
- List items from 'global_missing' or items in the user list but not in 'found_items'.
|
| 436 |
+
- Provide a realistic USD estimate for Zimbabwe.
|
| 437 |
+
|
| 438 |
+
3. **Totals 💰**
|
| 439 |
+
- Confirmed Total (Catalogue)
|
| 440 |
+
- Estimated Total (Missing Items)
|
| 441 |
+
- Grand Total Estimate
|
| 442 |
+
|
| 443 |
+
4. **Ideas & Tips 💡**
|
| 444 |
+
- Based on the user's event (e.g. Braai, Dinner) or items, suggest 3 creative ideas or forgotten items.
|
| 445 |
+
- Be fun but practical.
|
| 446 |
+
|
| 447 |
+
Tone: Helpful, Zimbabwean. Use Markdown tables for lists.
|
| 448 |
+
"""
|
| 449 |
try:
|
| 450 |
resp = _gemini_client.models.generate_content(
|
| 451 |
model=GEMINI_MODEL,
|
| 452 |
contents=PROMPT
|
| 453 |
)
|
| 454 |
return resp.text
|
| 455 |
+
except Exception as e:
|
| 456 |
+
logger.error(f"Creative Gen Error: {e}")
|
| 457 |
+
return "# Error\nCould not generate plan."
|
| 458 |
|
| 459 |
# =========================
|
| 460 |
# 4. Endpoints
|
|
|
|
| 471 |
|
| 472 |
@app.post("/chat")
|
| 473 |
def chat():
|
| 474 |
+
"""Text Chat - Uses simple summary."""
|
| 475 |
body = request.get_json(silent=True) or {}
|
| 476 |
msg = body.get("message", "")
|
| 477 |
pid = body.get("profile_id")
|
| 478 |
|
| 479 |
if not pid: return jsonify({"ok": False}), 400
|
| 480 |
|
|
|
|
| 481 |
intent_data = gemini_detect_intent(msg)
|
| 482 |
analyst_data = {}
|
| 483 |
|
|
|
|
| 484 |
if intent_data.get("actionable"):
|
| 485 |
if intent_data["intent"] == "SHOPPING_BASKET" and intent_data.get("items"):
|
| 486 |
analyst_data = calculate_basket_optimization(intent_data["items"])
|
| 487 |
elif intent_data["intent"] == "UTILITY_CALC":
|
| 488 |
analyst_data = calculate_zesa_units(intent_data.get("utility_amount", 20))
|
| 489 |
elif intent_data["intent"] == "PRODUCT_SEARCH" and intent_data.get("items"):
|
|
|
|
| 490 |
analyst_data = calculate_basket_optimization(intent_data["items"])
|
| 491 |
|
| 492 |
+
# Quick chat summary
|
| 493 |
+
reply = "I'm processing that for you."
|
| 494 |
+
if analyst_data:
|
| 495 |
+
try:
|
| 496 |
+
resp = _gemini_client.models.generate_content(
|
| 497 |
+
model=GEMINI_MODEL,
|
| 498 |
+
contents=f"Summarize this shopping data for chat:\n{json.dumps(analyst_data)}"
|
| 499 |
+
)
|
| 500 |
+
reply = resp.text
|
| 501 |
+
except: pass
|
| 502 |
+
|
| 503 |
if db:
|
| 504 |
db.collection("pricelyst_profiles").document(pid).collection("chat_logs").add({
|
| 505 |
"message": msg,
|
| 506 |
+
"response": reply,
|
| 507 |
"intent": intent_data,
|
| 508 |
"ts": datetime.now(timezone.utc).isoformat()
|
| 509 |
})
|
|
|
|
| 513 |
@app.post("/api/call-briefing")
|
| 514 |
def call_briefing():
|
| 515 |
"""
|
| 516 |
+
Injects Memory + Top 60 Mini-Catalogue.
|
| 517 |
"""
|
| 518 |
body = request.get_json(silent=True) or {}
|
| 519 |
pid = body.get("profile_id")
|
|
|
|
| 529 |
prof = doc.to_dict()
|
| 530 |
else:
|
| 531 |
ref.set({"created_at": datetime.now(timezone.utc).isoformat()})
|
| 532 |
+
|
| 533 |
if username and username != prof.get("username"):
|
| 534 |
if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
|
| 535 |
|
| 536 |
+
# --- Mini-Catalogue Generation ---
|
| 537 |
df = get_market_index()
|
| 538 |
+
catalogue_str = ""
|
| 539 |
if not df.empty:
|
| 540 |
+
# Top 60 Popular items with Price
|
| 541 |
+
top = df[df['is_offer']].sort_values('views', ascending=False).drop_duplicates('product_name').head(60)
|
| 542 |
+
lines = []
|
| 543 |
+
for _, r in top.iterrows():
|
| 544 |
+
lines.append(f"{r['product_name']} (${r['price']:.2f})")
|
| 545 |
+
catalogue_str = ", ".join(lines)
|
| 546 |
+
|
|
|
|
|
|
|
| 547 |
kpi_snapshot = {
|
| 548 |
"market_rates": ZIM_UTILITIES,
|
| 549 |
+
"popular_products": catalogue_str
|
| 550 |
}
|
| 551 |
|
| 552 |
return jsonify({
|
|
|
|
| 558 |
@app.post("/api/log-call-usage")
|
| 559 |
def log_call_usage():
|
| 560 |
"""
|
| 561 |
+
The Post-Call Orchestrator.
|
| 562 |
+
1. Update Memory.
|
| 563 |
+
2. Detect Intent.
|
| 564 |
+
3. Run Analyst.
|
| 565 |
+
4. Generate 4-Step Creative Plan.
|
| 566 |
+
5. Save.
|
| 567 |
"""
|
| 568 |
body = request.get_json(silent=True) or {}
|
| 569 |
pid = body.get("profile_id")
|
|
|
|
| 573 |
|
| 574 |
logger.info(f"Log Call: Processing {pid}. Transcript Len: {len(transcript)}")
|
| 575 |
|
| 576 |
+
# 1. Update Memory
|
| 577 |
if len(transcript) > 20 and db:
|
| 578 |
try:
|
| 579 |
curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
|
| 580 |
+
mem_prompt = f"Update user memory with details from transcript:\nOLD: {curr_mem}\nTRANSCRIPT: {transcript}"
|
| 581 |
mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
|
| 582 |
db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
|
| 583 |
except Exception as e:
|
| 584 |
+
logger.error(f"Memory Update Error: {e}")
|
| 585 |
|
| 586 |
+
# 2. Intent Detection
|
| 587 |
intent_data = gemini_detect_intent(transcript)
|
| 588 |
+
logger.info(f"Intent: {intent_data.get('intent')}")
|
| 589 |
|
| 590 |
plan_data = {}
|
| 591 |
|
| 592 |
# 3. Actionable Logic
|
| 593 |
if intent_data.get("actionable"):
|
| 594 |
|
| 595 |
+
# Run Analyst
|
| 596 |
if intent_data.get("items"):
|
| 597 |
analyst_result = calculate_basket_optimization(intent_data["items"])
|
| 598 |
|
| 599 |
+
# 4. Generate Creative Plan (4-Step)
|
| 600 |
+
# We generate if we have actionable basket results OR missing items
|
| 601 |
if analyst_result.get("actionable"):
|
| 602 |
+
md_content = gemini_generate_4step_plan(transcript, analyst_result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
|
| 604 |
plan_data = {
|
| 605 |
"is_actionable": True,
|
| 606 |
+
"title": f"Shopping Plan ({datetime.now().strftime('%d %b')})",
|
| 607 |
+
"markdown_content": md_content,
|
| 608 |
"items": intent_data["items"],
|
| 609 |
"created_at": datetime.now(timezone.utc).isoformat()
|
| 610 |
}
|
| 611 |
|
|
|
|
| 612 |
if db:
|
| 613 |
doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
|
| 614 |
plan_data["id"] = doc_ref.id
|
| 615 |
doc_ref.set(plan_data)
|
| 616 |
+
logger.info(f"Plan Saved: {doc_ref.id}")
|
| 617 |
|
| 618 |
+
# 5. Log Call
|
| 619 |
if db:
|
| 620 |
db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({
|
| 621 |
"transcript": transcript,
|
| 622 |
+
"intent": intent_data,
|
| 623 |
"plan_generated": bool(plan_data),
|
| 624 |
"ts": datetime.now(timezone.utc).isoformat()
|
| 625 |
})
|
|
|
|
| 629 |
"shopping_plan": plan_data if plan_data.get("is_actionable") else None
|
| 630 |
})
|
| 631 |
|
| 632 |
+
# ––––– CRUD –––––
|
| 633 |
|
| 634 |
@app.get("/api/shopping-plans")
|
| 635 |
def list_plans():
|
|
|
|
| 659 |
|
| 660 |
if __name__ == "__main__":
|
| 661 |
port = int(os.environ.get("PORT", 7860))
|
|
|
|
| 662 |
try:
|
| 663 |
get_market_index(force_refresh=True)
|
| 664 |
except:
|