Spaces:
Running
Running
Update main.py
Browse files
main.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
"""
|
| 2 |
-
main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v2.
|
| 3 |
|
| 4 |
-
✅ Fixed:
|
| 5 |
-
✅
|
| 6 |
-
✅
|
| 7 |
-
✅ "Analyst Engine": Enhanced
|
| 8 |
✅ "Visual Engine": Lists, Products, & Meal-to-Recipe recognition.
|
| 9 |
-
✅ Memory Logic: Short-Term Sliding Window
|
| 10 |
|
| 11 |
ENV VARS:
|
| 12 |
- GOOGLE_API_KEY=...
|
|
@@ -115,7 +115,7 @@ app = Flask(__name__)
|
|
| 115 |
CORS(app)
|
| 116 |
|
| 117 |
# =========================
|
| 118 |
-
# 1. ETL Layer (Ingestion)
|
| 119 |
# =========================
|
| 120 |
|
| 121 |
def _norm(s: Any) -> str:
|
|
@@ -140,7 +140,10 @@ def _safe_json_loads(s: str, fallback: Any):
|
|
| 140 |
return fallback
|
| 141 |
|
| 142 |
def fetch_and_flatten_data() -> pd.DataFrame:
|
| 143 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 144 |
all_products = []
|
| 145 |
page = 1
|
| 146 |
|
|
@@ -170,26 +173,35 @@ def fetch_and_flatten_data() -> pd.DataFrame:
|
|
| 170 |
try:
|
| 171 |
p_id = int(p.get("id") or 0)
|
| 172 |
p_name = str(p.get("name") or "Unknown")
|
| 173 |
-
clean_name = _norm(p_name)
|
| 174 |
-
|
| 175 |
-
cat_obj = p.get("category") or {}
|
| 176 |
-
cat_name = str(cat_obj.get("name") or "General")
|
| 177 |
|
|
|
|
| 178 |
brand_obj = p.get("brand") or {}
|
| 179 |
brand_name = str(brand_obj.get("brand_name") or "")
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
views = int(p.get("view_count") or 0)
|
| 182 |
image = str(p.get("thumbnail") or p.get("image") or "")
|
| 183 |
|
| 184 |
prices = p.get("prices") or []
|
| 185 |
|
| 186 |
if not prices:
|
|
|
|
| 187 |
rows.append({
|
| 188 |
"product_id": p_id,
|
| 189 |
"product_name": p_name,
|
| 190 |
-
"
|
| 191 |
"brand": brand_name,
|
| 192 |
-
"category":
|
| 193 |
"retailer": "Listing",
|
| 194 |
"price": 0.0,
|
| 195 |
"views": views,
|
|
@@ -207,9 +219,9 @@ def fetch_and_flatten_data() -> pd.DataFrame:
|
|
| 207 |
rows.append({
|
| 208 |
"product_id": p_id,
|
| 209 |
"product_name": p_name,
|
| 210 |
-
"
|
| 211 |
"brand": brand_name,
|
| 212 |
-
"category":
|
| 213 |
"retailer": r_name,
|
| 214 |
"price": price_val,
|
| 215 |
"views": views,
|
|
@@ -234,54 +246,56 @@ def get_market_index(force_refresh: bool = False) -> pd.DataFrame:
|
|
| 234 |
return _data_cache["df"]
|
| 235 |
|
| 236 |
# =========================
|
| 237 |
-
# 2. Analyst Engine (
|
| 238 |
# =========================
|
| 239 |
|
| 240 |
-
def
|
|
|
|
|
|
|
|
|
|
| 241 |
if df.empty or not query: return df
|
| 242 |
q_norm = _norm(query)
|
| 243 |
|
| 244 |
-
# 1.
|
| 245 |
-
|
| 246 |
-
matches = df[
|
| 247 |
|
| 248 |
-
# 2. Token overlap fallback
|
| 249 |
if matches.empty:
|
| 250 |
q_tokens = set(q_norm.split())
|
| 251 |
def token_score(text):
|
| 252 |
if not isinstance(text, str): return 0
|
| 253 |
text_tokens = set(text.split())
|
| 254 |
if not text_tokens: return 0
|
| 255 |
-
|
| 256 |
-
return len(intersection)
|
| 257 |
|
| 258 |
df_scored = df.copy()
|
| 259 |
-
df_scored['score'] = df_scored['
|
| 260 |
matches = df_scored[df_scored['score'] > 0]
|
| 261 |
|
| 262 |
if matches.empty: return matches
|
| 263 |
|
| 264 |
-
# 3. Sort
|
| 265 |
matches = matches.sort_values(by=['views', 'price'], ascending=[False, True])
|
| 266 |
return matches.head(limit)
|
| 267 |
|
| 268 |
-
def
|
| 269 |
-
if
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
return
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
df = get_market_index()
|
| 286 |
if df.empty:
|
| 287 |
return {"actionable": False, "error": "No data"}
|
|
@@ -289,89 +303,66 @@ def calculate_basket_optimization(item_names: List[str]) -> Dict[str, Any]:
|
|
| 289 |
found_items = []
|
| 290 |
missing_global = []
|
| 291 |
|
|
|
|
| 292 |
for item in item_names:
|
| 293 |
-
hits =
|
|
|
|
| 294 |
if hits.empty:
|
| 295 |
missing_global.append(item)
|
| 296 |
continue
|
| 297 |
|
| 298 |
-
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
found_items.append({
|
| 302 |
-
"query":
|
| 303 |
-
"
|
| 304 |
-
"
|
| 305 |
-
"
|
| 306 |
-
"
|
| 307 |
-
"
|
| 308 |
-
"category_stats": cat_stats
|
| 309 |
})
|
| 310 |
|
| 311 |
if not found_items:
|
| 312 |
-
return {
|
| 313 |
-
"actionable": True,
|
| 314 |
-
"basket_items": [],
|
| 315 |
-
"global_missing": missing_global,
|
| 316 |
-
"best_store": None,
|
| 317 |
-
"split_strategy": None
|
| 318 |
-
}
|
| 319 |
-
|
| 320 |
-
target_pids = [x['product_id'] for x in found_items]
|
| 321 |
-
relevant_offers = df[df['product_id'].isin(target_pids) & df['is_offer']]
|
| 322 |
-
|
| 323 |
-
retailer_stats = []
|
| 324 |
-
all_retailers = relevant_offers['retailer'].unique()
|
| 325 |
-
|
| 326 |
-
for retailer in all_retailers:
|
| 327 |
-
r_df = relevant_offers[relevant_offers['retailer'] == retailer]
|
| 328 |
-
found_count = len(r_df)
|
| 329 |
-
total_price = r_df['price'].sum()
|
| 330 |
-
|
| 331 |
-
retailer_pids = r_df['product_id'].tolist()
|
| 332 |
-
found_names = [x['name'] for x in found_items if x['product_id'] in retailer_pids]
|
| 333 |
-
|
| 334 |
-
retailer_stats.append({
|
| 335 |
-
"retailer": str(retailer),
|
| 336 |
-
"total_price": float(total_price),
|
| 337 |
-
"item_count": int(found_count),
|
| 338 |
-
"coverage_percent": float((found_count / len(found_items)) * 100),
|
| 339 |
-
"found_items": found_names
|
| 340 |
-
})
|
| 341 |
-
|
| 342 |
-
retailer_stats.sort(key=lambda x: (-x['coverage_percent'], x['total_price']))
|
| 343 |
-
best_single_store = retailer_stats[0] if retailer_stats else None
|
| 344 |
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
for item in found_items:
|
| 349 |
-
p_offers = relevant_offers[relevant_offers['product_id'] == item['product_id']]
|
| 350 |
-
if not p_offers.empty:
|
| 351 |
-
best_offer = p_offers.sort_values('price').iloc[0]
|
| 352 |
-
split_total += best_offer['price']
|
| 353 |
-
split_basket.append({
|
| 354 |
-
"item": item['name'],
|
| 355 |
-
"retailer": str(best_offer['retailer']),
|
| 356 |
-
"price": float(best_offer['price'])
|
| 357 |
-
})
|
| 358 |
|
| 359 |
-
|
| 360 |
-
"total_price": float(split_total),
|
| 361 |
-
"breakdown": split_basket,
|
| 362 |
-
"store_count": len(set(x['retailer'] for x in split_basket))
|
| 363 |
-
}
|
| 364 |
-
|
| 365 |
-
return {
|
| 366 |
"actionable": True,
|
| 367 |
-
"
|
| 368 |
-
"
|
| 369 |
"global_missing": missing_global,
|
| 370 |
-
"
|
| 371 |
-
"split_strategy": split_strategy,
|
| 372 |
-
"all_stores": retailer_stats[:3]
|
| 373 |
}
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
| 376 |
remaining = amount_usd / 1.06
|
| 377 |
units = 0.0
|
|
@@ -423,22 +414,23 @@ def gemini_detect_intent(transcript: str) -> Dict[str, Any]:
|
|
| 423 |
PROMPT = """
|
| 424 |
Analyze transcript. Return STRICT JSON.
|
| 425 |
Classify intent:
|
| 426 |
-
- CASUAL_CHAT: Greetings,
|
| 427 |
-
- SHOPPING_BASKET: Looking for prices, products,
|
| 428 |
- UTILITY_CALC: Electricity/ZESA questions.
|
| 429 |
- STORE_DECISION: "Where should I buy?", "Which store is cheapest?".
|
| 430 |
-
- TRUST_CHECK: "Is this expensive?", "Is this a good deal?".
|
| 431 |
|
| 432 |
Extract:
|
| 433 |
-
- items: list of products found
|
| 434 |
- utility_amount: number
|
|
|
|
| 435 |
|
| 436 |
JSON Schema:
|
| 437 |
{
|
| 438 |
"actionable": boolean,
|
| 439 |
"intent": "string",
|
| 440 |
"items": ["string"],
|
| 441 |
-
"utility_amount": number
|
|
|
|
| 442 |
}
|
| 443 |
"""
|
| 444 |
try:
|
|
@@ -458,17 +450,15 @@ def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
|
|
| 458 |
PROMPT = f"""
|
| 459 |
Analyze this image. Context: {caption}
|
| 460 |
1. SHOPPING LIST? -> Extract items.
|
| 461 |
-
2. SINGLE PRODUCT? -> Extract
|
| 462 |
-
3. MEAL/DISH? -> Identify
|
| 463 |
-
4. IRRELEVANT
|
| 464 |
-
|
| 465 |
-
IMPORTANT: If type is 'PRODUCT', the 'items' list MUST contain the product name. Do not leave it empty.
|
| 466 |
|
| 467 |
Return STRICT JSON:
|
| 468 |
{{
|
| 469 |
"type": "LIST" | "PRODUCT" | "MEAL" | "IRRELEVANT",
|
| 470 |
"items": ["item1"],
|
| 471 |
-
"description": "Short description
|
| 472 |
}}
|
| 473 |
"""
|
| 474 |
try:
|
|
@@ -482,7 +472,6 @@ def gemini_analyze_image(image_b64: str, caption: str = "") -> Dict[str, Any]:
|
|
| 482 |
config=types.GenerateContentConfig(response_mime_type="application/json")
|
| 483 |
)
|
| 484 |
result = _safe_json_loads(resp.text, {"type": "IRRELEVANT", "items": []})
|
| 485 |
-
logger.info(f"🔮 VISION RAW: {json.dumps(result)}")
|
| 486 |
return result
|
| 487 |
except Exception as e:
|
| 488 |
logger.error(f"Vision Error: {e}")
|
|
@@ -495,7 +484,7 @@ def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, chat
|
|
| 495 |
context_str += f"ZIMBABWE CONTEXT: Fuel={ZIM_CONTEXT['fuel_petrol']}, ZESA Rate={ZIM_CONTEXT['zesa_step_1']['rate']}\n"
|
| 496 |
|
| 497 |
if analyst_data:
|
| 498 |
-
context_str += f"ANALYST DATA
|
| 499 |
|
| 500 |
PROMPT = f"""
|
| 501 |
You are Jessica, Pricelyst's Shopping Advisor (Zimbabwe).
|
|
@@ -507,22 +496,25 @@ def gemini_chat_response(transcript: str, intent: Dict, analyst_data: Dict, chat
|
|
| 507 |
CONTEXT:
|
| 508 |
{context_str}
|
| 509 |
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
-
|
| 514 |
-
-
|
| 515 |
-
-
|
| 516 |
-
|
| 517 |
-
2. **
|
| 518 |
-
-
|
| 519 |
-
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
-
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
| 526 |
"""
|
| 527 |
|
| 528 |
try:
|
|
@@ -539,18 +531,13 @@ def gemini_generate_4step_plan(transcript: str, analyst_result: Dict) -> str:
|
|
| 539 |
if not _gemini_client: return "# Error\nAI Offline."
|
| 540 |
|
| 541 |
PROMPT = f"""
|
| 542 |
-
Generate a formatted Markdown Shopping Plan
|
| 543 |
-
|
| 544 |
DATA: {json.dumps(analyst_result, indent=2, default=str)}
|
| 545 |
-
|
| 546 |
SECTIONS:
|
| 547 |
-
1. **
|
| 548 |
-
2. **
|
| 549 |
3. **Recommendation 💡**
|
| 550 |
-
- "Best Single Store" vs "Split & Save".
|
| 551 |
4. **Budget Tips**
|
| 552 |
-
|
| 553 |
-
Make it look professional yet friendly.
|
| 554 |
"""
|
| 555 |
try:
|
| 556 |
resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=PROMPT)
|
|
@@ -569,59 +556,48 @@ def health():
|
|
| 569 |
"ok": True,
|
| 570 |
"offers_indexed": len(df),
|
| 571 |
"api_source": PRICE_API_BASE,
|
| 572 |
-
"persona": "Jessica v2.
|
| 573 |
})
|
| 574 |
|
| 575 |
@app.post("/chat")
|
| 576 |
def chat():
|
| 577 |
-
"""
|
| 578 |
-
Unified Text Chat Endpoint.
|
| 579 |
-
Uses SHORT-TERM SLIDING WINDOW memory only.
|
| 580 |
-
"""
|
| 581 |
body = request.get_json(silent=True) or {}
|
| 582 |
msg = body.get("message", "")
|
| 583 |
pid = body.get("profile_id")
|
| 584 |
|
| 585 |
if not pid: return jsonify({"ok": False, "error": "Missing profile_id"}), 400
|
| 586 |
|
| 587 |
-
#
|
| 588 |
history_str = ""
|
| 589 |
if db:
|
| 590 |
try:
|
| 591 |
-
# Get last 6 messages
|
| 592 |
docs = db.collection("pricelyst_profiles").document(pid).collection("chat_logs") \
|
| 593 |
.order_by("ts", direction=firestore.Query.DESCENDING).limit(6).stream()
|
| 594 |
-
|
| 595 |
-
msgs =
|
| 596 |
-
|
| 597 |
-
data = d.to_dict()
|
| 598 |
-
msgs.append(f"User: {data.get('message')}\nJessica: {data.get('response')}")
|
| 599 |
-
|
| 600 |
-
if msgs:
|
| 601 |
-
history_str = "\n".join(reversed(msgs))
|
| 602 |
-
except Exception as e:
|
| 603 |
-
logger.error(f"History Fetch Error: {e}")
|
| 604 |
|
| 605 |
-
#
|
| 606 |
intent_data = gemini_detect_intent(msg)
|
| 607 |
intent_type = intent_data.get("intent", "CASUAL_CHAT")
|
| 608 |
items = intent_data.get("items", [])
|
|
|
|
| 609 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
analyst_data = {}
|
| 611 |
|
| 612 |
-
# 3. Data Processing (The Analyst)
|
| 613 |
-
# Trigger Analyst if Items exist OR intent is specifically about shopping/decisions
|
| 614 |
if items or intent_type in ["SHOPPING_BASKET", "STORE_DECISION", "TRUST_CHECK"]:
|
| 615 |
-
analyst_data = calculate_basket_optimization(items)
|
| 616 |
|
| 617 |
elif intent_type == "UTILITY_CALC":
|
| 618 |
amount = intent_data.get("utility_amount", 20)
|
| 619 |
analyst_data = calculate_zesa_units(amount)
|
| 620 |
|
| 621 |
-
# 4. Response Generation (The Persona)
|
| 622 |
reply = gemini_chat_response(msg, intent_data, analyst_data, history_str)
|
| 623 |
|
| 624 |
-
# 5. Async Logging
|
| 625 |
if db:
|
| 626 |
db.collection("pricelyst_profiles").document(pid).collection("chat_logs").add({
|
| 627 |
"message": msg,
|
|
@@ -630,20 +606,10 @@ def chat():
|
|
| 630 |
"ts": datetime.now(timezone.utc).isoformat()
|
| 631 |
})
|
| 632 |
|
| 633 |
-
return jsonify({
|
| 634 |
-
"ok": True,
|
| 635 |
-
"data": {
|
| 636 |
-
"message": reply,
|
| 637 |
-
"analyst_debug": analyst_data if items else None
|
| 638 |
-
}
|
| 639 |
-
})
|
| 640 |
|
| 641 |
@app.post("/api/analyze-image")
|
| 642 |
def analyze_image():
|
| 643 |
-
"""
|
| 644 |
-
Handles Image -> List/Product/Meal -> Shopping Data
|
| 645 |
-
AUTO-RESOLVES intent with Context-Aware Simulation.
|
| 646 |
-
"""
|
| 647 |
body = request.get_json(silent=True) or {}
|
| 648 |
image_b64 = body.get("image_data")
|
| 649 |
caption = body.get("caption", "")
|
|
@@ -651,53 +617,34 @@ def analyze_image():
|
|
| 651 |
|
| 652 |
if not image_b64 or not pid: return jsonify({"ok": False}), 400
|
| 653 |
|
| 654 |
-
# 1. Vision Analysis
|
| 655 |
vision_result = gemini_analyze_image(image_b64, caption)
|
| 656 |
img_type = vision_result.get("type", "IRRELEVANT")
|
| 657 |
items = vision_result.get("items", [])
|
| 658 |
description = vision_result.get("description", "an image")
|
| 659 |
|
| 660 |
-
# Fallback
|
| 661 |
if (img_type in ["PRODUCT", "MEAL"]) and not items and description:
|
| 662 |
items = [description]
|
| 663 |
-
logger.info(f"🔮 Fallback: Used description '{description}' as item.")
|
| 664 |
|
| 665 |
response_text = ""
|
| 666 |
analyst_data = {}
|
| 667 |
|
| 668 |
-
# 2. Logic Branching
|
| 669 |
if img_type == "IRRELEVANT" and not items:
|
| 670 |
-
|
| 671 |
-
prompt = f"User uploaded a photo of: {description}. If it is a pet/flower/view, compliment it warmly! Then effectively explain you are a shopping bot and can't price check that."
|
| 672 |
response_text = gemini_chat_response(prompt, {"intent": "CASUAL_CHAT"}, {}, "")
|
| 673 |
|
| 674 |
elif items:
|
| 675 |
-
# Run the Analyst Engine
|
| 676 |
analyst_data = calculate_basket_optimization(items)
|
| 677 |
|
| 678 |
-
|
| 679 |
-
if img_type == "MEAL":
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
elif img_type == "LIST":
|
| 684 |
-
simulated_user_msg = f"Here is my list: {', '.join(items)}. What are the prices?"
|
| 685 |
-
intent_sim = {"intent": "STORE_DECISION"}
|
| 686 |
-
|
| 687 |
-
else: # PRODUCT
|
| 688 |
-
simulated_user_msg = f"I see {description}. What is the price for {', '.join(items)}?"
|
| 689 |
-
intent_sim = {"intent": "STORE_DECISION"}
|
| 690 |
|
| 691 |
-
|
| 692 |
-
response_text = gemini_chat_response(
|
| 693 |
-
simulated_user_msg,
|
| 694 |
-
intent_sim,
|
| 695 |
-
analyst_data,
|
| 696 |
-
chat_history=""
|
| 697 |
-
)
|
| 698 |
|
| 699 |
else:
|
| 700 |
-
response_text = "I couldn't
|
| 701 |
|
| 702 |
return jsonify({
|
| 703 |
"ok": True,
|
|
@@ -709,115 +656,65 @@ def analyze_image():
|
|
| 709 |
|
| 710 |
@app.post("/api/call-briefing")
|
| 711 |
def call_briefing():
|
| 712 |
-
|
| 713 |
-
Injects LONG-TERM Memory + Context for Voice Bot.
|
| 714 |
-
"""
|
| 715 |
body = request.get_json(silent=True) or {}
|
| 716 |
pid = body.get("profile_id")
|
| 717 |
username = body.get("username")
|
| 718 |
-
|
| 719 |
if not pid: return jsonify({"ok": False}), 400
|
| 720 |
-
|
| 721 |
prof = {}
|
| 722 |
if db:
|
| 723 |
ref = db.collection("pricelyst_profiles").document(pid)
|
| 724 |
doc = ref.get()
|
| 725 |
-
if doc.exists:
|
| 726 |
-
|
| 727 |
-
else:
|
| 728 |
-
ref.set({"created_at": datetime.now(timezone.utc).isoformat()})
|
| 729 |
-
|
| 730 |
if username and username != prof.get("username"):
|
| 731 |
if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
|
| 732 |
-
|
| 733 |
-
# Mini-Catalogue
|
| 734 |
df = get_market_index()
|
| 735 |
catalogue_str = ""
|
| 736 |
if not df.empty:
|
| 737 |
top = df[df['is_offer']].sort_values('views', ascending=False).drop_duplicates('product_name').head(60)
|
| 738 |
lines = [f"{r['product_name']} (~${r['price']:.2f})" for _, r in top.iterrows()]
|
| 739 |
catalogue_str = ", ".join(lines)
|
| 740 |
-
|
| 741 |
-
kpi_snapshot
|
| 742 |
-
"market_rates": ZIM_CONTEXT,
|
| 743 |
-
"popular_products": catalogue_str
|
| 744 |
-
}
|
| 745 |
-
|
| 746 |
-
return jsonify({
|
| 747 |
-
"ok": True,
|
| 748 |
-
"memory_summary": prof.get("memory_summary", ""),
|
| 749 |
-
"kpi_snapshot": json.dumps(kpi_snapshot)
|
| 750 |
-
})
|
| 751 |
|
| 752 |
@app.post("/api/log-call-usage")
|
| 753 |
def log_call_usage():
|
| 754 |
-
|
| 755 |
-
Post-Call Orchestrator.
|
| 756 |
-
Generates Plans & Updates Long-Term Memory.
|
| 757 |
-
"""
|
| 758 |
body = request.get_json(silent=True) or {}
|
| 759 |
pid = body.get("profile_id")
|
| 760 |
transcript = body.get("transcript", "")
|
| 761 |
-
|
| 762 |
if not pid: return jsonify({"ok": False}), 400
|
| 763 |
-
|
| 764 |
-
# 1. Update Long-Term Memory
|
| 765 |
if len(transcript) > 20 and db:
|
| 766 |
try:
|
| 767 |
curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
|
| 768 |
-
mem_prompt = f"Update user memory (budget, family size
|
| 769 |
mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
|
| 770 |
db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
|
| 771 |
-
except
|
| 772 |
-
logger.error(f"Memory Update Error: {e}")
|
| 773 |
-
|
| 774 |
-
# 2. Plan Generation
|
| 775 |
intent_data = gemini_detect_intent(transcript)
|
| 776 |
plan_data = {}
|
| 777 |
-
|
| 778 |
if intent_data.get("actionable") and intent_data.get("items"):
|
| 779 |
analyst_result = calculate_basket_optimization(intent_data["items"])
|
| 780 |
-
|
| 781 |
if analyst_result.get("actionable"):
|
| 782 |
md_content = gemini_generate_4step_plan(transcript, analyst_result)
|
| 783 |
-
|
| 784 |
-
plan_data = {
|
| 785 |
-
"is_actionable": True,
|
| 786 |
-
"title": f"Shopping Plan ({datetime.now().strftime('%d %b')})",
|
| 787 |
-
"markdown_content": md_content,
|
| 788 |
-
"items": intent_data["items"],
|
| 789 |
-
"created_at": datetime.now(timezone.utc).isoformat()
|
| 790 |
-
}
|
| 791 |
-
|
| 792 |
if db:
|
| 793 |
doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
|
| 794 |
plan_data["id"] = doc_ref.id
|
| 795 |
doc_ref.set(plan_data)
|
| 796 |
-
|
| 797 |
if db:
|
| 798 |
-
db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({
|
| 799 |
-
|
| 800 |
-
"intent": intent_data,
|
| 801 |
-
"plan_generated": bool(plan_data),
|
| 802 |
-
"ts": datetime.now(timezone.utc).isoformat()
|
| 803 |
-
})
|
| 804 |
-
|
| 805 |
-
return jsonify({
|
| 806 |
-
"ok": True,
|
| 807 |
-
"shopping_plan": plan_data if plan_data.get("is_actionable") else None
|
| 808 |
-
})
|
| 809 |
|
| 810 |
@app.get("/api/shopping-plans")
|
| 811 |
def list_plans():
|
| 812 |
pid = request.args.get("profile_id")
|
| 813 |
if not pid or not db: return jsonify({"ok": False}), 400
|
| 814 |
try:
|
| 815 |
-
docs = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans")
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
return jsonify({"ok": True, "plans": plans})
|
| 819 |
-
except:
|
| 820 |
-
return jsonify({"ok": False}), 500
|
| 821 |
|
| 822 |
@app.delete("/api/shopping-plans/<plan_id>")
|
| 823 |
def delete_plan(plan_id):
|
|
@@ -826,17 +723,10 @@ def delete_plan(plan_id):
|
|
| 826 |
try:
|
| 827 |
db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document(plan_id).delete()
|
| 828 |
return jsonify({"ok": True})
|
| 829 |
-
except:
|
| 830 |
-
return jsonify({"ok": False}), 500
|
| 831 |
-
|
| 832 |
-
# =========================
|
| 833 |
-
# Main
|
| 834 |
-
# =========================
|
| 835 |
|
| 836 |
if __name__ == "__main__":
|
| 837 |
port = int(os.environ.get("PORT", 7860))
|
| 838 |
-
try:
|
| 839 |
-
|
| 840 |
-
except:
|
| 841 |
-
pass
|
| 842 |
app.run(host="0.0.0.0", port=port)
|
|
|
|
| 1 |
"""
|
| 2 |
+
main.py — Pricelyst Shopping Advisor (Jessica Edition 2026 - Upgrade v2.6)
|
| 3 |
|
| 4 |
+
✅ Fixed: Search Regression (Now searches Brand, Category, & Description).
|
| 5 |
+
✅ Feature: Store Preference Detection ("Price at OK Mart?").
|
| 6 |
+
✅ Logic: Single Item (Best First) vs Basket (Cheapest Total).
|
| 7 |
+
✅ "Analyst Engine": Enhanced Data Flattening & Comparison Logic.
|
| 8 |
✅ "Visual Engine": Lists, Products, & Meal-to-Recipe recognition.
|
| 9 |
+
✅ Memory Logic: Short-Term Sliding Window.
|
| 10 |
|
| 11 |
ENV VARS:
|
| 12 |
- GOOGLE_API_KEY=...
|
|
|
|
| 115 |
CORS(app)
|
| 116 |
|
| 117 |
# =========================
|
| 118 |
+
# 1. ETL Layer (Ingestion - Deep Flattening)
|
| 119 |
# =========================
|
| 120 |
|
| 121 |
def _norm(s: Any) -> str:
|
|
|
|
| 140 |
return fallback
|
| 141 |
|
| 142 |
def fetch_and_flatten_data() -> pd.DataFrame:
|
| 143 |
+
"""
|
| 144 |
+
Fetches product data and creates a 'search_vector' for deep fuzzy matching.
|
| 145 |
+
Includes: Name, Brand, Category Strings.
|
| 146 |
+
"""
|
| 147 |
all_products = []
|
| 148 |
page = 1
|
| 149 |
|
|
|
|
| 173 |
try:
|
| 174 |
p_id = int(p.get("id") or 0)
|
| 175 |
p_name = str(p.get("name") or "Unknown")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
+
# --- Deep Metadata Extraction ---
|
| 178 |
brand_obj = p.get("brand") or {}
|
| 179 |
brand_name = str(brand_obj.get("brand_name") or "")
|
| 180 |
|
| 181 |
+
# Extract ALL category names (parent, sub, etc.)
|
| 182 |
+
cats = p.get("categories") or []
|
| 183 |
+
cat_names = [str(c.get("name") or "") for c in cats]
|
| 184 |
+
cat_str = " ".join(cat_names)
|
| 185 |
+
|
| 186 |
+
# Base Category (for grouping)
|
| 187 |
+
primary_cat = cat_names[0] if cat_names else "General"
|
| 188 |
+
|
| 189 |
+
# Create a Search Vector: "Top Chef Jasmine Rice Rice & Pasta Groceries"
|
| 190 |
+
search_vector = _norm(f"{p_name} {brand_name} {cat_str}")
|
| 191 |
+
|
| 192 |
views = int(p.get("view_count") or 0)
|
| 193 |
image = str(p.get("thumbnail") or p.get("image") or "")
|
| 194 |
|
| 195 |
prices = p.get("prices") or []
|
| 196 |
|
| 197 |
if not prices:
|
| 198 |
+
# No Price? Still index for "Out of Stock" awareness
|
| 199 |
rows.append({
|
| 200 |
"product_id": p_id,
|
| 201 |
"product_name": p_name,
|
| 202 |
+
"search_vector": search_vector, # KEY UPGRADE
|
| 203 |
"brand": brand_name,
|
| 204 |
+
"category": primary_cat,
|
| 205 |
"retailer": "Listing",
|
| 206 |
"price": 0.0,
|
| 207 |
"views": views,
|
|
|
|
| 219 |
rows.append({
|
| 220 |
"product_id": p_id,
|
| 221 |
"product_name": p_name,
|
| 222 |
+
"search_vector": search_vector, # KEY UPGRADE
|
| 223 |
"brand": brand_name,
|
| 224 |
+
"category": primary_cat,
|
| 225 |
"retailer": r_name,
|
| 226 |
"price": price_val,
|
| 227 |
"views": views,
|
|
|
|
| 246 |
return _data_cache["df"]
|
| 247 |
|
| 248 |
# =========================
|
| 249 |
+
# 2. Analyst Engine (Smart Search & Logic)
|
| 250 |
# =========================
|
| 251 |
|
| 252 |
+
def search_products_deep(df: pd.DataFrame, query: str, limit: int = 15) -> pd.DataFrame:
|
| 253 |
+
"""
|
| 254 |
+
Searches against the 'search_vector' (Name + Brand + Categories).
|
| 255 |
+
"""
|
| 256 |
if df.empty or not query: return df
|
| 257 |
q_norm = _norm(query)
|
| 258 |
|
| 259 |
+
# 1. Direct match in vector
|
| 260 |
+
mask = df['search_vector'].str.contains(q_norm, regex=False)
|
| 261 |
+
matches = df[mask].copy()
|
| 262 |
|
| 263 |
+
# 2. Token overlap fallback (if query is "Cheap Rice", matches "Rice")
|
| 264 |
if matches.empty:
|
| 265 |
q_tokens = set(q_norm.split())
|
| 266 |
def token_score(text):
|
| 267 |
if not isinstance(text, str): return 0
|
| 268 |
text_tokens = set(text.split())
|
| 269 |
if not text_tokens: return 0
|
| 270 |
+
return len(q_tokens.intersection(text_tokens))
|
|
|
|
| 271 |
|
| 272 |
df_scored = df.copy()
|
| 273 |
+
df_scored['score'] = df_scored['search_vector'].apply(token_score)
|
| 274 |
matches = df_scored[df_scored['score'] > 0]
|
| 275 |
|
| 276 |
if matches.empty: return matches
|
| 277 |
|
| 278 |
+
# 3. Sort: Views (Popularity) -> Price (Low)
|
| 279 |
matches = matches.sort_values(by=['views', 'price'], ascending=[False, True])
|
| 280 |
return matches.head(limit)
|
| 281 |
|
| 282 |
+
def detect_retailer_preference(query: str) -> Optional[str]:
|
| 283 |
+
"""Detects if user asked for a specific store."""
|
| 284 |
+
query = query.lower()
|
| 285 |
+
# Hardcoded known retailers for robustness
|
| 286 |
+
known_stores = ["ok mart", "ok supermarket", "tm pick n pay", "pick n pay", "spar", "food lovers", "choppies", "gains"]
|
| 287 |
+
for store in known_stores:
|
| 288 |
+
if store in query:
|
| 289 |
+
return store # Return the detected string to match loosely
|
| 290 |
+
return None
|
| 291 |
+
|
| 292 |
+
def calculate_basket_optimization(item_names: List[str], preferred_retailer: str = None) -> Dict[str, Any]:
|
| 293 |
+
"""
|
| 294 |
+
The Core Logic:
|
| 295 |
+
- Single Item: Returns 'Best Option' + 'Others'.
|
| 296 |
+
- Basket: Returns 'Best Basket' + 'Breakdown'.
|
| 297 |
+
- Preference: Filters for specific store if requested.
|
| 298 |
+
"""
|
| 299 |
df = get_market_index()
|
| 300 |
if df.empty:
|
| 301 |
return {"actionable": False, "error": "No data"}
|
|
|
|
| 303 |
found_items = []
|
| 304 |
missing_global = []
|
| 305 |
|
| 306 |
+
# 1. Resolve Items
|
| 307 |
for item in item_names:
|
| 308 |
+
hits = search_products_deep(df[df['is_offer']==True], item, limit=10)
|
| 309 |
+
|
| 310 |
if hits.empty:
|
| 311 |
missing_global.append(item)
|
| 312 |
continue
|
| 313 |
|
| 314 |
+
# Group hits by Product Name to aggregate offers
|
| 315 |
+
# We take the most popular product match
|
| 316 |
+
best_product_name = hits.iloc[0]['product_name']
|
| 317 |
+
product_offers = hits[hits['product_name'] == best_product_name]
|
| 318 |
+
|
| 319 |
+
# Sort offers: Price Ascending
|
| 320 |
+
product_offers = product_offers.sort_values('price', ascending=True)
|
| 321 |
|
| 322 |
+
offers_list = []
|
| 323 |
+
for _, r in product_offers.iterrows():
|
| 324 |
+
offers_list.append({
|
| 325 |
+
"retailer": r['retailer'],
|
| 326 |
+
"price": float(r['price'])
|
| 327 |
+
})
|
| 328 |
+
|
| 329 |
found_items.append({
|
| 330 |
+
"query": item,
|
| 331 |
+
"product_name": best_product_name,
|
| 332 |
+
"category": str(hits.iloc[0]['category']),
|
| 333 |
+
"offers": offers_list, # All available prices for this item
|
| 334 |
+
"best_price": offers_list[0]['price'],
|
| 335 |
+
"best_retailer": offers_list[0]['retailer']
|
|
|
|
| 336 |
})
|
| 337 |
|
| 338 |
if not found_items:
|
| 339 |
+
return {"actionable": True, "found_items": [], "global_missing": missing_global}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
|
| 341 |
+
# 2. Logic: Single vs Multi
|
| 342 |
+
is_basket = len(found_items) > 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
|
| 344 |
+
result = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
"actionable": True,
|
| 346 |
+
"is_basket": is_basket,
|
| 347 |
+
"found_items": found_items,
|
| 348 |
"global_missing": missing_global,
|
| 349 |
+
"preferred_retailer": preferred_retailer
|
|
|
|
|
|
|
| 350 |
}
|
| 351 |
|
| 352 |
+
# 3. Store Preference Logic (User asked: "Rice at OK Mart?")
|
| 353 |
+
if preferred_retailer and not is_basket:
|
| 354 |
+
item = found_items[0]
|
| 355 |
+
# Find the offer from the preferred store
|
| 356 |
+
pref_offer = next((o for o in item['offers'] if preferred_retailer.lower() in o['retailer'].lower()), None)
|
| 357 |
+
result['preferred_offer'] = pref_offer
|
| 358 |
+
result['comparison_vs_best'] = None
|
| 359 |
+
|
| 360 |
+
if pref_offer:
|
| 361 |
+
diff = pref_offer['price'] - item['best_price']
|
| 362 |
+
result['comparison_vs_best'] = diff # +ve means preferred is expensive, 0 means best
|
| 363 |
+
|
| 364 |
+
return result
|
| 365 |
+
|
| 366 |
def calculate_zesa_units(amount_usd: float) -> Dict[str, Any]:
|
| 367 |
remaining = amount_usd / 1.06
|
| 368 |
units = 0.0
|
|
|
|
| 414 |
PROMPT = """
|
| 415 |
Analyze transcript. Return STRICT JSON.
|
| 416 |
Classify intent:
|
| 417 |
+
- CASUAL_CHAT: Greetings, "hi".
|
| 418 |
+
- SHOPPING_BASKET: Looking for prices, products, "cheapest X".
|
| 419 |
- UTILITY_CALC: Electricity/ZESA questions.
|
| 420 |
- STORE_DECISION: "Where should I buy?", "Which store is cheapest?".
|
|
|
|
| 421 |
|
| 422 |
Extract:
|
| 423 |
+
- items: list of products found.
|
| 424 |
- utility_amount: number
|
| 425 |
+
- store_preference: if a specific store is named (e.g. "at OK Mart").
|
| 426 |
|
| 427 |
JSON Schema:
|
| 428 |
{
|
| 429 |
"actionable": boolean,
|
| 430 |
"intent": "string",
|
| 431 |
"items": ["string"],
|
| 432 |
+
"utility_amount": number,
|
| 433 |
+
"store_preference": "string"
|
| 434 |
}
|
| 435 |
"""
|
| 436 |
try:
|
|
|
|
| 450 |
PROMPT = f"""
|
| 451 |
Analyze this image. Context: {caption}
|
| 452 |
1. SHOPPING LIST? -> Extract items.
|
| 453 |
+
2. SINGLE PRODUCT? -> Extract BRAND + NAME (e.g. "Pepsi 500ml").
|
| 454 |
+
3. MEAL/DISH? -> Identify dish + ingredients.
|
| 455 |
+
4. IRRELEVANT? -> Return type "IRRELEVANT".
|
|
|
|
|
|
|
| 456 |
|
| 457 |
Return STRICT JSON:
|
| 458 |
{{
|
| 459 |
"type": "LIST" | "PRODUCT" | "MEAL" | "IRRELEVANT",
|
| 460 |
"items": ["item1"],
|
| 461 |
+
"description": "Short description"
|
| 462 |
}}
|
| 463 |
"""
|
| 464 |
try:
|
|
|
|
| 472 |
config=types.GenerateContentConfig(response_mime_type="application/json")
|
| 473 |
)
|
| 474 |
result = _safe_json_loads(resp.text, {"type": "IRRELEVANT", "items": []})
|
|
|
|
| 475 |
return result
|
| 476 |
except Exception as e:
|
| 477 |
logger.error(f"Vision Error: {e}")
|
|
|
|
| 484 |
context_str += f"ZIMBABWE CONTEXT: Fuel={ZIM_CONTEXT['fuel_petrol']}, ZESA Rate={ZIM_CONTEXT['zesa_step_1']['rate']}\n"
|
| 485 |
|
| 486 |
if analyst_data:
|
| 487 |
+
context_str += f"ANALYST DATA: {json.dumps(analyst_data, default=str)}\n"
|
| 488 |
|
| 489 |
PROMPT = f"""
|
| 490 |
You are Jessica, Pricelyst's Shopping Advisor (Zimbabwe).
|
|
|
|
| 496 |
CONTEXT:
|
| 497 |
{context_str}
|
| 498 |
|
| 499 |
+
LOGIC RULES (Strict Adherence):
|
| 500 |
+
|
| 501 |
+
1. **SINGLE ITEM QUERY** (e.g. "Price of Rice"):
|
| 502 |
+
- **Primary**: State the CHEAPEST option immediately. "I found [Product] at [Retailer] for **$[Price]**."
|
| 503 |
+
- **Comparison**: List 1-2 other options. "Also available at [Store B] ($X) and [Store C] ($Y)."
|
| 504 |
+
- **Store Preference**: If user asked "Rice at OK Mart?", state that price FIRST, then say if it's cheaper elsewhere.
|
| 505 |
+
|
| 506 |
+
2. **BASKET QUERY** (e.g. "Rice, Oil, and Soap"):
|
| 507 |
+
- Provide the **Total Basket Cost** at the cheapest single store.
|
| 508 |
+
- Provide the Breakdown.
|
| 509 |
+
- Mention if splitting stores saves significant money.
|
| 510 |
+
|
| 511 |
+
3. **MISSING ITEMS**:
|
| 512 |
+
- Be honest. "I couldn't find a current price for [Item]."
|
| 513 |
+
|
| 514 |
+
4. **CASUAL**:
|
| 515 |
+
- Reset context if user says "Hi".
|
| 516 |
+
|
| 517 |
+
TONE: Helpful, direct, Zimbabwean. Use Markdown for prices.
|
| 518 |
"""
|
| 519 |
|
| 520 |
try:
|
|
|
|
| 531 |
if not _gemini_client: return "# Error\nAI Offline."
|
| 532 |
|
| 533 |
PROMPT = f"""
|
| 534 |
+
Generate a formatted Markdown Shopping Plan.
|
|
|
|
| 535 |
DATA: {json.dumps(analyst_result, indent=2, default=str)}
|
|
|
|
| 536 |
SECTIONS:
|
| 537 |
+
1. **Catalogue Found ✅** (Table: Item | Store | Price)
|
| 538 |
+
2. **Missing 😔** (Estimates)
|
| 539 |
3. **Recommendation 💡**
|
|
|
|
| 540 |
4. **Budget Tips**
|
|
|
|
|
|
|
| 541 |
"""
|
| 542 |
try:
|
| 543 |
resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=PROMPT)
|
|
|
|
| 556 |
"ok": True,
|
| 557 |
"offers_indexed": len(df),
|
| 558 |
"api_source": PRICE_API_BASE,
|
| 559 |
+
"persona": "Jessica v2.6 (Deep Search)"
|
| 560 |
})
|
| 561 |
|
| 562 |
@app.post("/chat")
|
| 563 |
def chat():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
body = request.get_json(silent=True) or {}
|
| 565 |
msg = body.get("message", "")
|
| 566 |
pid = body.get("profile_id")
|
| 567 |
|
| 568 |
if not pid: return jsonify({"ok": False, "error": "Missing profile_id"}), 400
|
| 569 |
|
| 570 |
+
# History
|
| 571 |
history_str = ""
|
| 572 |
if db:
|
| 573 |
try:
|
|
|
|
| 574 |
docs = db.collection("pricelyst_profiles").document(pid).collection("chat_logs") \
|
| 575 |
.order_by("ts", direction=firestore.Query.DESCENDING).limit(6).stream()
|
| 576 |
+
msgs = [f"User: {d.to_dict().get('message')}\nJessica: {d.to_dict().get('response')}" for d in docs]
|
| 577 |
+
if msgs: history_str = "\n".join(reversed(msgs))
|
| 578 |
+
except: pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
|
| 580 |
+
# Intent
|
| 581 |
intent_data = gemini_detect_intent(msg)
|
| 582 |
intent_type = intent_data.get("intent", "CASUAL_CHAT")
|
| 583 |
items = intent_data.get("items", [])
|
| 584 |
+
store_pref = intent_data.get("store_preference") # Extracted from Gemini
|
| 585 |
|
| 586 |
+
# Store Preference Override (RegEx backup)
|
| 587 |
+
if not store_pref:
|
| 588 |
+
store_pref = detect_retailer_preference(msg)
|
| 589 |
+
|
| 590 |
analyst_data = {}
|
| 591 |
|
|
|
|
|
|
|
| 592 |
if items or intent_type in ["SHOPPING_BASKET", "STORE_DECISION", "TRUST_CHECK"]:
|
| 593 |
+
analyst_data = calculate_basket_optimization(items, preferred_retailer=store_pref)
|
| 594 |
|
| 595 |
elif intent_type == "UTILITY_CALC":
|
| 596 |
amount = intent_data.get("utility_amount", 20)
|
| 597 |
analyst_data = calculate_zesa_units(amount)
|
| 598 |
|
|
|
|
| 599 |
reply = gemini_chat_response(msg, intent_data, analyst_data, history_str)
|
| 600 |
|
|
|
|
| 601 |
if db:
|
| 602 |
db.collection("pricelyst_profiles").document(pid).collection("chat_logs").add({
|
| 603 |
"message": msg,
|
|
|
|
| 606 |
"ts": datetime.now(timezone.utc).isoformat()
|
| 607 |
})
|
| 608 |
|
| 609 |
+
return jsonify({"ok": True, "data": {"message": reply, "analyst_debug": analyst_data if items else None}})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
|
| 611 |
@app.post("/api/analyze-image")
|
| 612 |
def analyze_image():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
body = request.get_json(silent=True) or {}
|
| 614 |
image_b64 = body.get("image_data")
|
| 615 |
caption = body.get("caption", "")
|
|
|
|
| 617 |
|
| 618 |
if not image_b64 or not pid: return jsonify({"ok": False}), 400
|
| 619 |
|
|
|
|
| 620 |
vision_result = gemini_analyze_image(image_b64, caption)
|
| 621 |
img_type = vision_result.get("type", "IRRELEVANT")
|
| 622 |
items = vision_result.get("items", [])
|
| 623 |
description = vision_result.get("description", "an image")
|
| 624 |
|
| 625 |
+
# Fallback for empty products
|
| 626 |
if (img_type in ["PRODUCT", "MEAL"]) and not items and description:
|
| 627 |
items = [description]
|
|
|
|
| 628 |
|
| 629 |
response_text = ""
|
| 630 |
analyst_data = {}
|
| 631 |
|
|
|
|
| 632 |
if img_type == "IRRELEVANT" and not items:
|
| 633 |
+
prompt = f"User uploaded photo of {description}. Compliment it if appropriate (pet/nature), then explain you are a shopping bot."
|
|
|
|
| 634 |
response_text = gemini_chat_response(prompt, {"intent": "CASUAL_CHAT"}, {}, "")
|
| 635 |
|
| 636 |
elif items:
|
|
|
|
| 637 |
analyst_data = calculate_basket_optimization(items)
|
| 638 |
|
| 639 |
+
sim_msg = ""
|
| 640 |
+
if img_type == "MEAL": sim_msg = f"I want to cook {description}. Cost of ingredients: {', '.join(items)}?"
|
| 641 |
+
elif img_type == "LIST": sim_msg = f"Price of list: {', '.join(items)}?"
|
| 642 |
+
else: sim_msg = f"Cheapest price for {', '.join(items)}?"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
|
| 644 |
+
response_text = gemini_chat_response(sim_msg, {"intent": "STORE_DECISION"}, analyst_data, "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
|
| 646 |
else:
|
| 647 |
+
response_text = "I couldn't identify the product. Could you type the name?"
|
| 648 |
|
| 649 |
return jsonify({
|
| 650 |
"ok": True,
|
|
|
|
| 656 |
|
| 657 |
@app.post("/api/call-briefing")
|
| 658 |
def call_briefing():
|
| 659 |
+
# ... (Same as before, abbreviated for length but logic remains)
|
|
|
|
|
|
|
| 660 |
body = request.get_json(silent=True) or {}
|
| 661 |
pid = body.get("profile_id")
|
| 662 |
username = body.get("username")
|
|
|
|
| 663 |
if not pid: return jsonify({"ok": False}), 400
|
|
|
|
| 664 |
prof = {}
|
| 665 |
if db:
|
| 666 |
ref = db.collection("pricelyst_profiles").document(pid)
|
| 667 |
doc = ref.get()
|
| 668 |
+
if doc.exists: prof = doc.to_dict()
|
| 669 |
+
else: ref.set({"created_at": datetime.now(timezone.utc).isoformat()})
|
|
|
|
|
|
|
|
|
|
| 670 |
if username and username != prof.get("username"):
|
| 671 |
if db: db.collection("pricelyst_profiles").document(pid).set({"username": username}, merge=True)
|
|
|
|
|
|
|
| 672 |
df = get_market_index()
|
| 673 |
catalogue_str = ""
|
| 674 |
if not df.empty:
|
| 675 |
top = df[df['is_offer']].sort_values('views', ascending=False).drop_duplicates('product_name').head(60)
|
| 676 |
lines = [f"{r['product_name']} (~${r['price']:.2f})" for _, r in top.iterrows()]
|
| 677 |
catalogue_str = ", ".join(lines)
|
| 678 |
+
kpi_snapshot = {"market_rates": ZIM_CONTEXT, "popular_products": catalogue_str}
|
| 679 |
+
return jsonify({"ok": True, "memory_summary": prof.get("memory_summary", ""), "kpi_snapshot": json.dumps(kpi_snapshot)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
|
| 681 |
@app.post("/api/log-call-usage")
|
| 682 |
def log_call_usage():
|
| 683 |
+
# ... (Same as before)
|
|
|
|
|
|
|
|
|
|
| 684 |
body = request.get_json(silent=True) or {}
|
| 685 |
pid = body.get("profile_id")
|
| 686 |
transcript = body.get("transcript", "")
|
|
|
|
| 687 |
if not pid: return jsonify({"ok": False}), 400
|
|
|
|
|
|
|
| 688 |
if len(transcript) > 20 and db:
|
| 689 |
try:
|
| 690 |
curr_mem = db.collection("pricelyst_profiles").document(pid).get().to_dict().get("memory_summary", "")
|
| 691 |
+
mem_prompt = f"Update user memory (budget, family size) based on: {transcript}\nOLD: {curr_mem}"
|
| 692 |
mem_resp = _gemini_client.models.generate_content(model=GEMINI_MODEL, contents=mem_prompt)
|
| 693 |
db.collection("pricelyst_profiles").document(pid).set({"memory_summary": mem_resp.text}, merge=True)
|
| 694 |
+
except: pass
|
|
|
|
|
|
|
|
|
|
| 695 |
intent_data = gemini_detect_intent(transcript)
|
| 696 |
plan_data = {}
|
|
|
|
| 697 |
if intent_data.get("actionable") and intent_data.get("items"):
|
| 698 |
analyst_result = calculate_basket_optimization(intent_data["items"])
|
|
|
|
| 699 |
if analyst_result.get("actionable"):
|
| 700 |
md_content = gemini_generate_4step_plan(transcript, analyst_result)
|
| 701 |
+
plan_data = {"is_actionable": True, "title": f"Plan {datetime.now().strftime('%d %b')}", "markdown_content": md_content, "items": intent_data["items"], "created_at": datetime.now(timezone.utc).isoformat()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
if db:
|
| 703 |
doc_ref = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document()
|
| 704 |
plan_data["id"] = doc_ref.id
|
| 705 |
doc_ref.set(plan_data)
|
|
|
|
| 706 |
if db:
|
| 707 |
+
db.collection("pricelyst_profiles").document(pid).collection("call_logs").add({"transcript": transcript, "intent": intent_data, "plan_generated": bool(plan_data), "ts": datetime.now(timezone.utc).isoformat()})
|
| 708 |
+
return jsonify({"ok": True, "shopping_plan": plan_data if plan_data.get("is_actionable") else None})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 709 |
|
| 710 |
@app.get("/api/shopping-plans")
|
| 711 |
def list_plans():
|
| 712 |
pid = request.args.get("profile_id")
|
| 713 |
if not pid or not db: return jsonify({"ok": False}), 400
|
| 714 |
try:
|
| 715 |
+
docs = db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").order_by("created_at", direction=firestore.Query.DESCENDING).limit(10).stream()
|
| 716 |
+
return jsonify({"ok": True, "plans": [{"id": d.id, **d.to_dict()} for d in docs]})
|
| 717 |
+
except: return jsonify({"ok": False}), 500
|
|
|
|
|
|
|
|
|
|
| 718 |
|
| 719 |
@app.delete("/api/shopping-plans/<plan_id>")
|
| 720 |
def delete_plan(plan_id):
|
|
|
|
| 723 |
try:
|
| 724 |
db.collection("pricelyst_profiles").document(pid).collection("shopping_plans").document(plan_id).delete()
|
| 725 |
return jsonify({"ok": True})
|
| 726 |
+
except: return jsonify({"ok": False}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
|
| 728 |
if __name__ == "__main__":
|
| 729 |
port = int(os.environ.get("PORT", 7860))
|
| 730 |
+
try: get_market_index(force_refresh=True)
|
| 731 |
+
except: pass
|
|
|
|
|
|
|
| 732 |
app.run(host="0.0.0.0", port=port)
|