Spaces:
Running
Running
Update main.py
Browse files
main.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v2.
|
| 3 |
|
| 4 |
✅ Flask API
|
| 5 |
✅ Firebase Admin Persistence
|
|
@@ -8,6 +8,7 @@ main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v2.0)
|
|
| 8 |
✅ "Jessica Engine": Natural Conversation, Chit-Chat, & Advisory
|
| 9 |
✅ "Visual Engine": Lists, Products, & Meal-to-Recipe recognition
|
| 10 |
✅ Type Safety: Explicit Casting for JSON Serialization
|
|
|
|
| 11 |
|
| 12 |
ENV VARS:
|
| 13 |
- GOOGLE_API_KEY=...
|
|
@@ -184,7 +185,6 @@ def fetch_and_flatten_data() -> pd.DataFrame:
|
|
| 184 |
|
| 185 |
prices = p.get("prices") or []
|
| 186 |
|
| 187 |
-
# If no prices, still index it for context but mark as no offer
|
| 188 |
if not prices:
|
| 189 |
rows.append({
|
| 190 |
"product_id": p_id,
|
|
@@ -270,10 +270,8 @@ def search_products_fuzzy(df: pd.DataFrame, query: str, limit: int = 10) -> pd.D
|
|
| 270 |
def get_category_stats(df: pd.DataFrame, category_name: str) -> Dict[str, Any]:
|
| 271 |
"""Returns min, max, avg price for a category to determine value."""
|
| 272 |
if df.empty: return {}
|
| 273 |
-
# Loose matching on category or name
|
| 274 |
cat_df = df[df['category'].str.lower().str.contains(category_name.lower()) & df['is_offer']]
|
| 275 |
if cat_df.empty:
|
| 276 |
-
# Try name matching if category fails
|
| 277 |
cat_df = df[df['clean_name'].str.contains(category_name.lower()) & df['is_offer']]
|
| 278 |
|
| 279 |
if cat_df.empty: return {}
|
|
@@ -288,9 +286,7 @@ def get_category_stats(df: pd.DataFrame, category_name: str) -> Dict[str, Any]:
|
|
| 288 |
|
| 289 |
def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
| 290 |
"""
|
| 291 |
-
Optimizes for:
|
| 292 |
-
1. Best Single Store (Convenience)
|
| 293 |
-
2. Split Store (Cheapest possible mix)
|
| 294 |
"""
|
| 295 |
df = get_market_index()
|
| 296 |
if df.empty:
|
|
@@ -299,7 +295,6 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
|
| 299 |
found_items = []
|
| 300 |
missing_global = []
|
| 301 |
|
| 302 |
-
# 1. Resolve Items
|
| 303 |
for item in item_names:
|
| 304 |
hits = search_products_fuzzy(df[df['is_offer']==True], item, limit=5)
|
| 305 |
if hits.empty:
|
|
@@ -307,8 +302,6 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
|
| 307 |
continue
|
| 308 |
|
| 309 |
best_prod = hits.iloc[0]
|
| 310 |
-
|
| 311 |
-
# Get category stats for the first item to help with "is this expensive" logic later
|
| 312 |
cat_stats = get_category_stats(df, str(best_prod['category']))
|
| 313 |
|
| 314 |
found_items.append({
|
|
@@ -328,7 +321,6 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
|
| 328 |
"split_strategy": None
|
| 329 |
}
|
| 330 |
|
| 331 |
-
# 2. Calculate Retailer Totals (Single Store Strategy)
|
| 332 |
target_pids = [x['product_id'] for x in found_items]
|
| 333 |
relevant_offers = df[df['product_id'].isin(target_pids) & df['is_offer']]
|
| 334 |
|
|
@@ -354,13 +346,10 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
|
| 354 |
retailer_stats.sort(key=lambda x: (-x['coverage_percent'], x['total_price']))
|
| 355 |
best_single_store = retailer_stats[0] if retailer_stats else None
|
| 356 |
|
| 357 |
-
# 3. Calculate Split Strategy (Cheapest Global)
|
| 358 |
-
# For each found item, find the absolute minimum price across all stores
|
| 359 |
split_basket = []
|
| 360 |
split_total = 0.0
|
| 361 |
|
| 362 |
for item in found_items:
|
| 363 |
-
# Find offers for this specific product ID
|
| 364 |
p_offers = relevant_offers[relevant_offers['product_id'] == item['product_id']]
|
| 365 |
if not p_offers.empty:
|
| 366 |
best_offer = p_offers.sort_values('price').iloc[0]
|
|
@@ -399,7 +388,7 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
|
| 399 |
if remaining > cost_t1:
|
| 400 |
units += t1["limit"]
|
| 401 |
remaining -= cost_t1
|
| 402 |
-
breakdown.append(f"First {t1['limit']}
|
| 403 |
|
| 404 |
t2 = ZIM_CONTEXT["zesa_step_2"]
|
| 405 |
cost_t2 = t2["limit"] * t2["rate"]
|
|
@@ -407,20 +396,20 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
|
| 407 |
if remaining > cost_t2:
|
| 408 |
units += t2["limit"]
|
| 409 |
remaining -= cost_t2
|
| 410 |
-
breakdown.append(f"Next {t2['limit']}
|
| 411 |
|
| 412 |
t3 = ZIM_CONTEXT["zesa_step_3"]
|
| 413 |
bought = remaining / t3["rate"]
|
| 414 |
units += bought
|
| 415 |
-
breakdown.append(f"
|
| 416 |
else:
|
| 417 |
bought = remaining / t2["rate"]
|
| 418 |
units += bought
|
| 419 |
-
breakdown.append(f"
|
| 420 |
else:
|
| 421 |
bought = remaining / t1["rate"]
|
| 422 |
units += bought
|
| 423 |
-
breakdown.append(f"All
|
| 424 |
|
| 425 |
return {
|
| 426 |
"amount_usd": float(amount_usd),
|
|
@@ -434,10 +423,6 @@ def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
|
| 434 |
# =========================
|
| 435 |
|
| 436 |
def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
| 437 |
-
"""
|
| 438 |
-
Expanded Intent Classification.
|
| 439 |
-
Detects: CASUAL, SHOPPING_BASKET, UTILITY, STORE_DECISION, TRUST_CHECK
|
| 440 |
-
"""
|
| 441 |
if not _gemini_client: return {"actionable": False}
|
| 442 |
|
| 443 |
PROMPT = """
|
|
@@ -452,7 +437,7 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
|
| 452 |
Extract:
|
| 453 |
- items: list of products
|
| 454 |
- utility_amount: number
|
| 455 |
-
- context: "budget", "speed", "quality"
|
| 456 |
|
| 457 |
JSON Schema:
|
| 458 |
{
|
|
@@ -475,10 +460,6 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
|
| 475 |
return {"actionable": False, "intent": "CASUAL_CHAT"}
|
| 476 |
|
| 477 |
def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
|
| 478 |
-
"""
|
| 479 |
-
Multimodal Image Analysis.
|
| 480 |
-
Determines if image is a Shopping List, Product, or Meal.
|
| 481 |
-
"""
|
| 482 |
if not _gemini_client: return {"error": "AI Offline"}
|
| 483 |
|
| 484 |
PROMPT = f"""
|
|
@@ -505,20 +486,17 @@ def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
|
|
| 505 |
],
|
| 506 |
config=types.GenerateContentConfig(response_mime_type="application/json")
|
| 507 |
)
|
| 508 |
-
|
|
|
|
|
|
|
| 509 |
except Exception as e:
|
| 510 |
logger.error(f"Vision Error: {e}")
|
| 511 |
return {"type": "IRRELEVANT", "items": []}
|
| 512 |
|
| 513 |
-
def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict,
|
| 514 |
-
"""
|
| 515 |
-
The Persona Engine (Jessica).
|
| 516 |
-
Synthesizes Analyst Data + ZESA Context + Memory into a natural response.
|
| 517 |
-
"""
|
| 518 |
if not _gemini_client: return "I'm having trouble connecting to my brain right now."
|
| 519 |
|
| 520 |
-
|
| 521 |
-
context_str = f"USER MEMORY: {memory_summary}\n" if memory_summary else ""
|
| 522 |
context_str += f"ZIMBABWE CONTEXT: Fuel={ZIM_CONTEXT['fuel_petrol']}, ZESA Rate={ZIM_CONTEXT['zesa_step_1']['rate']}\n"
|
| 523 |
|
| 524 |
if analyst_data:
|
|
@@ -534,7 +512,7 @@ def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, memo
|
|
| 534 |
{context_str}
|
| 535 |
|
| 536 |
INSTRUCTIONS:
|
| 537 |
-
1. **Casual Chat**: If
|
| 538 |
2. **Shopping Advice**:
|
| 539 |
- If data exists, guide them. "I found XYZ at Store A for $5."
|
| 540 |
- If 'best_store' exists, recommend it explicitly based on coverage.
|
|
@@ -544,7 +522,7 @@ def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, memo
|
|
| 544 |
- If price is > avg, say "That's a bit pricey, average is $X."
|
| 545 |
4. **ZESA**: Explain the units naturally using the breakdown provided.
|
| 546 |
|
| 547 |
-
TONE: Conversational, Zimbabwean English
|
| 548 |
Do NOT dump JSON. Write a natural message. Use Markdown for lists/prices.
|
| 549 |
"""
|
| 550 |
|
|
@@ -559,9 +537,6 @@ def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, memo
|
|
| 559 |
return "I found the data, but I'm struggling to summarize it. Please check the plan below."
|
| 560 |
|
| 561 |
def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
|
| 562 |
-
"""
|
| 563 |
-
Generates the Formal Shopping Plan (Markdown).
|
| 564 |
-
"""
|
| 565 |
if not _gemini_client: return "# Error\nAI Offline."
|
| 566 |
|
| 567 |
PROMPT = f"""
|
|
@@ -573,8 +548,8 @@ def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
|
|
| 573 |
1. **In Our Catalogue ✅** (Table: Item | Store | Price)
|
| 574 |
2. **Not in Catalogue 😔** (Estimates)
|
| 575 |
3. **Recommendation 💡**
|
| 576 |
-
- "Best Single Store" vs "Split & Save"
|
| 577 |
-
4. **Budget Tips**
|
| 578 |
|
| 579 |
Make it look professional yet friendly.
|
| 580 |
"""
|
|
@@ -595,14 +570,14 @@ def health():
|
|
| 595 |
"ok": True,
|
| 596 |
"offers_indexed": len(df),
|
| 597 |
"api_source": PRICE_API_BASE,
|
| 598 |
-
"persona": "Jessica v2.
|
| 599 |
})
|
| 600 |
|
| 601 |
@app.post("/chat")
|
| 602 |
def chat():
|
| 603 |
"""
|
| 604 |
Unified Text Chat Endpoint.
|
| 605 |
-
|
| 606 |
"""
|
| 607 |
body = request.get_json(silent=True) or {}
|
| 608 |
msg = body.get("message", "")
|
|
@@ -610,11 +585,24 @@ def chat():
|
|
| 610 |
|
| 611 |
if not pid: return jsonify({"ok": False, "error": "Missing profile_id"}), 400
|
| 612 |
|
| 613 |
-
# 1.
|
| 614 |
-
|
| 615 |
if db:
|
| 616 |
-
|
| 617 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
|
| 619 |
# 2. Intent Detection
|
| 620 |
intent_data = gemini_detect_intent(msg)
|
|
@@ -632,7 +620,7 @@ def chat():
|
|
| 632 |
analyst_data = calculate_zesa_units(amount)
|
| 633 |
|
| 634 |
# 4. Response Generation (The Persona)
|
| 635 |
-
reply = gemini_chat_response(msg, intent_data, analyst_data,
|
| 636 |
|
| 637 |
# 5. Async Logging
|
| 638 |
if db:
|
|
@@ -654,7 +642,7 @@ def chat():
|
|
| 654 |
@app.post("/api/analyze-image")
|
| 655 |
def analyze_image():
|
| 656 |
"""
|
| 657 |
-
|
| 658 |
"""
|
| 659 |
body = request.get_json(silent=True) or {}
|
| 660 |
image_b64 = body.get("image_data") # Base64 string
|
|
@@ -676,10 +664,10 @@ def analyze_image():
|
|
| 676 |
response_text = "I see the image, but I can't find any shopping items or meals in it. Try a receipt, a product, or a plate of food!"
|
| 677 |
|
| 678 |
elif items:
|
| 679 |
-
# Run the Analyst Engine
|
| 680 |
analyst_data = calculate_basket_optimization(items)
|
| 681 |
|
| 682 |
-
# Craft a specific prompt for image results
|
| 683 |
intent_sim = {"intent": "SHOPPING_BASKET"}
|
| 684 |
response_text = gemini_chat_response(
|
| 685 |
f"User uploaded image of {img_type}: {vision_result.get('description')}. Items found: {items}",
|
|
@@ -698,7 +686,7 @@ def analyze_image():
|
|
| 698 |
@app.post("/api/call-briefing")
|
| 699 |
def call_briefing():
|
| 700 |
"""
|
| 701 |
-
Injects Memory + Context for Voice Bot.
|
| 702 |
"""
|
| 703 |
body = request.get_json(silent=True) or {}
|
| 704 |
pid = body.get("profile_id")
|
|
@@ -718,7 +706,7 @@ def call_briefing():
|
|
| 718 |
if username and username != prof.get("username"):
|
| 719 |
if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
|
| 720 |
|
| 721 |
-
# Mini-Catalogue
|
| 722 |
df = get_market_index()
|
| 723 |
catalogue_str = ""
|
| 724 |
if not df.empty:
|
|
@@ -733,7 +721,7 @@ def call_briefing():
|
|
| 733 |
|
| 734 |
return jsonify({
|
| 735 |
"ok": True,
|
| 736 |
-
"memory_summary": prof.get("memory_summary", ""),
|
| 737 |
"kpi_snapshot": json.dumps(kpi_snapshot)
|
| 738 |
})
|
| 739 |
|
|
@@ -741,7 +729,7 @@ def call_briefing():
|
|
| 741 |
def log_call_usage():
|
| 742 |
"""
|
| 743 |
Post-Call Orchestrator.
|
| 744 |
-
Generates Plans & Updates Memory.
|
| 745 |
"""
|
| 746 |
body = request.get_json(silent=True) or {}
|
| 747 |
pid = body.get("profile_id")
|
|
@@ -749,7 +737,7 @@ def log_call_usage():
|
|
| 749 |
|
| 750 |
if not pid: return jsonify({"ok": False}), 400
|
| 751 |
|
| 752 |
-
# 1. Update Memory
|
| 753 |
if len(transcript) > 20 and db:
|
| 754 |
try:
|
| 755 |
curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
|
|
|
|
| 1 |
"""
|
| 2 |
+
main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v2.1)
|
| 3 |
|
| 4 |
✅ Flask API
|
| 5 |
✅ Firebase Admin Persistence
|
|
|
|
| 8 |
✅ "Jessica Engine": Natural Conversation, Chit-Chat, & Advisory
|
| 9 |
✅ "Visual Engine": Lists, Products, & Meal-to-Recipe recognition
|
| 10 |
✅ Type Safety: Explicit Casting for JSON Serialization
|
| 11 |
+
✅ Memory Logic: Separated Deep Memory (Calls) from Short-Term Context (Chat)
|
| 12 |
|
| 13 |
ENV VARS:
|
| 14 |
- GOOGLE_API_KEY=...
|
|
|
|
| 185 |
|
| 186 |
prices = p.get("prices") or []
|
| 187 |
|
|
|
|
| 188 |
if not prices:
|
| 189 |
rows.append({
|
| 190 |
"product_id": p_id,
|
|
|
|
| 270 |
def get_category_stats(df: pd.DataFrame, category_name: str) -> Dict[str, Any]:
|
| 271 |
"""Returns min, max, avg price for a category to determine value."""
|
| 272 |
if df.empty: return {}
|
|
|
|
| 273 |
cat_df = df[df['category'].str.lower().str.contains(category_name.lower()) & df['is_offer']]
|
| 274 |
if cat_df.empty:
|
|
|
|
| 275 |
cat_df = df[df['clean_name'].str.contains(category_name.lower()) & df['is_offer']]
|
| 276 |
|
| 277 |
if cat_df.empty: return {}
|
|
|
|
| 286 |
|
| 287 |
def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
| 288 |
"""
|
| 289 |
+
Optimizes for: 1. Best Single Store, 2. Cheapest Split Mix.
|
|
|
|
|
|
|
| 290 |
"""
|
| 291 |
df = get_market_index()
|
| 292 |
if df.empty:
|
|
|
|
| 295 |
found_items = []
|
| 296 |
missing_global = []
|
| 297 |
|
|
|
|
| 298 |
for item in item_names:
|
| 299 |
hits = search_products_fuzzy(df[df['is_offer']==True], item, limit=5)
|
| 300 |
if hits.empty:
|
|
|
|
| 302 |
continue
|
| 303 |
|
| 304 |
best_prod = hits.iloc[0]
|
|
|
|
|
|
|
| 305 |
cat_stats = get_category_stats(df, str(best_prod['category']))
|
| 306 |
|
| 307 |
found_items.append({
|
|
|
|
| 321 |
"split_strategy": None
|
| 322 |
}
|
| 323 |
|
|
|
|
| 324 |
target_pids = [x['product_id'] for x in found_items]
|
| 325 |
relevant_offers = df[df['product_id'].isin(target_pids) & df['is_offer']]
|
| 326 |
|
|
|
|
| 346 |
retailer_stats.sort(key=lambda x: (-x['coverage_percent'], x['total_price']))
|
| 347 |
best_single_store = retailer_stats[0] if retailer_stats else None
|
| 348 |
|
|
|
|
|
|
|
| 349 |
split_basket = []
|
| 350 |
split_total = 0.0
|
| 351 |
|
| 352 |
for item in found_items:
|
|
|
|
| 353 |
p_offers = relevant_offers[relevant_offers['product_id'] == item['product_id']]
|
| 354 |
if not p_offers.empty:
|
| 355 |
best_offer = p_offers.sort_values('price').iloc[0]
|
|
|
|
| 388 |
if remaining > cost_t1:
|
| 389 |
units += t1["limit"]
|
| 390 |
remaining -= cost_t1
|
| 391 |
+
breakdown.append(f"First {t1['limit']}u @ ${t1['rate']}")
|
| 392 |
|
| 393 |
t2 = ZIM_CONTEXT["zesa_step_2"]
|
| 394 |
cost_t2 = t2["limit"] * t2["rate"]
|
|
|
|
| 396 |
if remaining > cost_t2:
|
| 397 |
units += t2["limit"]
|
| 398 |
remaining -= cost_t2
|
| 399 |
+
breakdown.append(f"Next {t2['limit']}u @ ${t2['rate']}")
|
| 400 |
|
| 401 |
t3 = ZIM_CONTEXT["zesa_step_3"]
|
| 402 |
bought = remaining / t3["rate"]
|
| 403 |
units += bought
|
| 404 |
+
breakdown.append(f"Balance -> {bought:.1f}u @ ${t3['rate']}")
|
| 405 |
else:
|
| 406 |
bought = remaining / t2["rate"]
|
| 407 |
units += bought
|
| 408 |
+
breakdown.append(f"Balance -> {bought:.1f}u @ ${t2['rate']}")
|
| 409 |
else:
|
| 410 |
bought = remaining / t1["rate"]
|
| 411 |
units += bought
|
| 412 |
+
breakdown.append(f"All {bought:.1f}u @ ${t1['rate']}")
|
| 413 |
|
| 414 |
return {
|
| 415 |
"amount_usd": float(amount_usd),
|
|
|
|
| 423 |
# =========================
|
| 424 |
|
| 425 |
def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
if not _gemini_client: return {"actionable": False}
|
| 427 |
|
| 428 |
PROMPT = """
|
|
|
|
| 437 |
Extract:
|
| 438 |
- items: list of products
|
| 439 |
- utility_amount: number
|
| 440 |
+
- context: "budget", "speed", "quality"
|
| 441 |
|
| 442 |
JSON Schema:
|
| 443 |
{
|
|
|
|
| 460 |
return {"actionable": False, "intent": "CASUAL_CHAT"}
|
| 461 |
|
| 462 |
def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
if not _gemini_client: return {"error": "AI Offline"}
|
| 464 |
|
| 465 |
PROMPT = f"""
|
|
|
|
| 486 |
],
|
| 487 |
config=types.GenerateContentConfig(response_mime_type="application/json")
|
| 488 |
)
|
| 489 |
+
result = _safe_json_loads(resp.text, {"type": "IRRELEVANT", "items": []})
|
| 490 |
+
logger.info(f"🔮 VISION RAW: {json.dumps(result)}") # Debug Logging
|
| 491 |
+
return result
|
| 492 |
except Exception as e:
|
| 493 |
logger.error(f"Vision Error: {e}")
|
| 494 |
return {"type": "IRRELEVANT", "items": []}
|
| 495 |
|
| 496 |
+
def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, chat_history: str = "") -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 497 |
if not _gemini_client: return "I'm having trouble connecting to my brain right now."
|
| 498 |
|
| 499 |
+
context_str = f"RECENT CHAT HISTORY:\n{chat_history}\n" if chat_history else ""
|
|
|
|
| 500 |
context_str += f"ZIMBABWE CONTEXT: Fuel={ZIM_CONTEXT['fuel_petrol']}, ZESA Rate={ZIM_CONTEXT['zesa_step_1']['rate']}\n"
|
| 501 |
|
| 502 |
if analyst_data:
|
|
|
|
| 512 |
{context_str}
|
| 513 |
|
| 514 |
INSTRUCTIONS:
|
| 515 |
+
1. **Casual Chat**: Use the chat history to reply naturally. If history is empty, be warm. "Makadii! How can I help?"
|
| 516 |
2. **Shopping Advice**:
|
| 517 |
- If data exists, guide them. "I found XYZ at Store A for $5."
|
| 518 |
- If 'best_store' exists, recommend it explicitly based on coverage.
|
|
|
|
| 522 |
- If price is > avg, say "That's a bit pricey, average is $X."
|
| 523 |
4. **ZESA**: Explain the units naturally using the breakdown provided.
|
| 524 |
|
| 525 |
+
TONE: Conversational, Zimbabwean English.
|
| 526 |
Do NOT dump JSON. Write a natural message. Use Markdown for lists/prices.
|
| 527 |
"""
|
| 528 |
|
|
|
|
| 537 |
return "I found the data, but I'm struggling to summarize it. Please check the plan below."
|
| 538 |
|
| 539 |
def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
|
|
|
|
|
|
|
|
|
|
| 540 |
if not _gemini_client: return "# Error\nAI Offline."
|
| 541 |
|
| 542 |
PROMPT = f"""
|
|
|
|
| 548 |
1. **In Our Catalogue ✅** (Table: Item | Store | Price)
|
| 549 |
2. **Not in Catalogue 😔** (Estimates)
|
| 550 |
3. **Recommendation 💡**
|
| 551 |
+
- "Best Single Store" vs "Split & Save".
|
| 552 |
+
4. **Budget Tips**
|
| 553 |
|
| 554 |
Make it look professional yet friendly.
|
| 555 |
"""
|
|
|
|
| 570 |
"ok": True,
|
| 571 |
"offers_indexed": len(df),
|
| 572 |
"api_source": PRICE_API_BASE,
|
| 573 |
+
"persona": "Jessica v2.1 (Memory Firewall)"
|
| 574 |
})
|
| 575 |
|
| 576 |
@app.post("/chat")
|
| 577 |
def chat():
|
| 578 |
"""
|
| 579 |
Unified Text Chat Endpoint.
|
| 580 |
+
Uses SHORT-TERM SLIDING WINDOW memory only.
|
| 581 |
"""
|
| 582 |
body = request.get_json(silent=True) or {}
|
| 583 |
msg = body.get("message", "")
|
|
|
|
| 585 |
|
| 586 |
if not pid: return jsonify({"ok": False, "error": "Missing profile_id"}), 400
|
| 587 |
|
| 588 |
+
# 1. Fetch Short-Term History (Sliding Window)
|
| 589 |
+
history_str = ""
|
| 590 |
if db:
|
| 591 |
+
try:
|
| 592 |
+
# Get last 6 messages, ordered by time descending (newest first)
|
| 593 |
+
docs = db.collection("pricelyst_profiles").document(pid).collection("chat_logs") \
|
| 594 |
+
.order_by("ts", direction=firestore.Query.DESCENDING).limit(6).stream()
|
| 595 |
+
|
| 596 |
+
# Reverse to Chronological order for the LLM
|
| 597 |
+
msgs = []
|
| 598 |
+
for d in docs:
|
| 599 |
+
data = d.to_dict()
|
| 600 |
+
msgs.append(f"User: {data.get('message')}\nJessica: {data.get('response')}")
|
| 601 |
+
|
| 602 |
+
if msgs:
|
| 603 |
+
history_str = "\n".join(reversed(msgs))
|
| 604 |
+
except Exception as e:
|
| 605 |
+
logger.error(f"History Fetch Error: {e}")
|
| 606 |
|
| 607 |
# 2. Intent Detection
|
| 608 |
intent_data = gemini_detect_intent(msg)
|
|
|
|
| 620 |
analyst_data = calculate_zesa_units(amount)
|
| 621 |
|
| 622 |
# 4. Response Generation (The Persona)
|
| 623 |
+
reply = gemini_chat_response(msg, intent_data, analyst_data, history_str)
|
| 624 |
|
| 625 |
# 5. Async Logging
|
| 626 |
if db:
|
|
|
|
| 642 |
@app.post("/api/analyze-image")
|
| 643 |
def analyze_image():
|
| 644 |
"""
|
| 645 |
+
Handles Image -> List/Product/Meal -> Shopping Data
|
| 646 |
"""
|
| 647 |
body = request.get_json(silent=True) or {}
|
| 648 |
image_b64 = body.get("image_data") # Base64 string
|
|
|
|
| 664 |
response_text = "I see the image, but I can't find any shopping items or meals in it. Try a receipt, a product, or a plate of food!"
|
| 665 |
|
| 666 |
elif items:
|
| 667 |
+
# Run the Analyst Engine
|
| 668 |
analyst_data = calculate_basket_optimization(items)
|
| 669 |
|
| 670 |
+
# Craft a specific prompt for image results (No history needed here usually)
|
| 671 |
intent_sim = {"intent": "SHOPPING_BASKET"}
|
| 672 |
response_text = gemini_chat_response(
|
| 673 |
f"User uploaded image of {img_type}: {vision_result.get('description')}. Items found: {items}",
|
|
|
|
| 686 |
@app.post("/api/call-briefing")
|
| 687 |
def call_briefing():
|
| 688 |
"""
|
| 689 |
+
Injects LONG-TERM Memory + Context for Voice Bot.
|
| 690 |
"""
|
| 691 |
body = request.get_json(silent=True) or {}
|
| 692 |
pid = body.get("profile_id")
|
|
|
|
| 706 |
if username and username != prof.get("username"):
|
| 707 |
if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
|
| 708 |
|
| 709 |
+
# Mini-Catalogue
|
| 710 |
df = get_market_index()
|
| 711 |
catalogue_str = ""
|
| 712 |
if not df.empty:
|
|
|
|
| 721 |
|
| 722 |
return jsonify({
|
| 723 |
"ok": True,
|
| 724 |
+
"memory_summary": prof.get("memory_summary", ""), # Keep Long Term memory here
|
| 725 |
"kpi_snapshot": json.dumps(kpi_snapshot)
|
| 726 |
})
|
| 727 |
|
|
|
|
| 729 |
def log_call_usage():
|
| 730 |
"""
|
| 731 |
Post-Call Orchestrator.
|
| 732 |
+
Generates Plans & Updates Long-Term Memory.
|
| 733 |
"""
|
| 734 |
body = request.get_json(silent=True) or {}
|
| 735 |
pid = body.get("profile_id")
|
|
|
|
| 737 |
|
| 738 |
if not pid: return jsonify({"ok": False}), 400
|
| 739 |
|
| 740 |
+
# 1. Update Long-Term Memory
|
| 741 |
if len(transcript) > 20 and db:
|
| 742 |
try:
|
| 743 |
curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
|