murtaza-2007 commited on
Commit
9cb8eaf
·
1 Parent(s): 0a1e79f

Make kit edits incremental: pin existing picks/brand so add/remove doesn't reshuffle the whole bundle

Browse files
Files changed (2) hide show
  1. RAG_Products/chat.py +34 -7
  2. RAG_Products/kits.py +78 -3
RAG_Products/chat.py CHANGED
@@ -31,7 +31,7 @@ from RAG_Products.similar import match_product, rank_similar
31
  from RAG_Products.kits import (
32
  detect_kit,
33
  plan_categories,
34
- build_kit,
35
  best_single_brand_kit,
36
  )
37
  from RAG_Products.usecases import detect_use_case, rank_for_use_case
@@ -488,8 +488,16 @@ def _handle_kit(query, ent):
488
  return _build_kit_response(query, categories, budget, kit_name)
489
 
490
 
491
- def _build_kit_response(query, categories, budget, kit_name, prefix=None):
492
- """Shared kit assembly used by both fresh requests and follow-ups."""
 
 
 
 
 
 
 
 
493
  catalog = get_catalog()
494
  categories = [c for c in categories if c in get_stock_categories()]
495
  extra = []
@@ -509,17 +517,20 @@ def _build_kit_response(query, categories, budget, kit_name, prefix=None):
509
  "leftover": (budget - total) if budget else None,
510
  "items": items,
511
  "dropped": dropped,
 
512
  })
513
 
514
  # 1) same-brand bundle first (customer preference)
515
- same = best_single_brand_kit(categories, budget, catalog)
 
516
  if same:
517
  b_brand, b_ordered, b_total, b_inc, b_dropped = same
518
  add_block(f"{b_brand} — same-brand bundle", b_brand,
519
  b_ordered, b_total, b_inc, b_dropped)
520
 
521
  # 2) best-value mixed bundle
522
- m_ordered, m_total, m_inc, m_dropped = build_kit(categories, budget, catalog)
 
523
  add_block("Best value — mixed brands", None,
524
  m_ordered, m_total, m_inc, m_dropped)
525
 
@@ -545,6 +556,11 @@ def _build_kit_response(query, categories, budget, kit_name, prefix=None):
545
  # conversational memory: remember this kit for follow-ups
546
  resp["kit_categories"] = categories
547
  resp["kit_budget"] = budget
 
 
 
 
 
548
  return resp
549
 
550
 
@@ -554,7 +570,8 @@ _TERM_CATEGORY = {
554
  "audio": "Microphone / Audio", "sound": "Microphone / Audio",
555
  "camera": "Action Camera", "cam": "Action Camera",
556
  "action camera": "Action Camera", "gopro": "Action Camera",
557
- "gimbal": "Gimbal / Stabilizer", "stabilizer": "Gimbal / Stabilizer",
 
558
  "light": "Lighting", "lighting": "Lighting",
559
  "lens": "Lens", "tripod": "Tripod / Support", "stand": "Tripod / Support",
560
  "memory": "Memory / Storage", "card": "Memory / Storage", "sd": "Memory / Storage",
@@ -602,6 +619,9 @@ def _handle_kit_followup(query, fu, context):
602
  op, payload = fu
603
  categories = list(context.get("kit_categories") or [])
604
  budget = context.get("kit_budget")
 
 
 
605
  prefix = None
606
 
607
  if op == "add":
@@ -614,15 +634,22 @@ def _handle_kit_followup(query, fu, context):
614
  prefix = f"Updated kit — added {', '.join(c.lower() for c in added)}"
615
  elif op == "remove":
616
  categories = [c for c in categories if c not in payload]
 
 
 
617
  nice = ", ".join(c.lower() for c in payload)
618
  prefix = f"Updated kit — removed {nice}"
619
  elif op == "budget":
620
  budget = payload
621
  prefix = f"Rebuilt kit for {_money(budget)}"
 
 
622
 
623
  if not categories:
624
  return _empty(query, "kit", "That would empty the kit — nothing left to build.")
625
- return _build_kit_response(query, categories, budget, "updated", prefix=prefix)
 
 
626
 
627
 
628
  def _handle_use_case(query, ent):
 
31
  from RAG_Products.kits import (
32
  detect_kit,
33
  plan_categories,
34
+ build_kit_keep,
35
  best_single_brand_kit,
36
  )
37
  from RAG_Products.usecases import detect_use_case, rank_for_use_case
 
488
  return _build_kit_response(query, categories, budget, kit_name)
489
 
490
 
491
+ def _build_kit_response(query, categories, budget, kit_name, prefix=None,
492
+ keep_same=None, keep_mixed=None, lock_brand=None):
493
+ """Shared kit assembly used by both fresh requests and follow-ups.
494
+
495
+ `keep_same`/`keep_mixed` (cat -> product name) pin the previous bundle's
496
+ picks so editing a kit (add/remove a category, change budget) doesn't
497
+ reshuffle items that were already chosen — only the changed slice is
498
+ resolved fresh. `lock_brand` keeps the same-brand bundle on the brand it
499
+ already used.
500
+ """
501
  catalog = get_catalog()
502
  categories = [c for c in categories if c in get_stock_categories()]
503
  extra = []
 
517
  "leftover": (budget - total) if budget else None,
518
  "items": items,
519
  "dropped": dropped,
520
+ "picks": {d.metadata.get("category"): d.metadata.get("name") for d in ordered},
521
  })
522
 
523
  # 1) same-brand bundle first (customer preference)
524
+ same = best_single_brand_kit(categories, budget, catalog,
525
+ keep=keep_same, lock_brand=lock_brand)
526
  if same:
527
  b_brand, b_ordered, b_total, b_inc, b_dropped = same
528
  add_block(f"{b_brand} — same-brand bundle", b_brand,
529
  b_ordered, b_total, b_inc, b_dropped)
530
 
531
  # 2) best-value mixed bundle
532
+ m_ordered, m_total, m_inc, m_dropped = build_kit_keep(
533
+ categories, budget, catalog, keep_mixed or {})
534
  add_block("Best value — mixed brands", None,
535
  m_ordered, m_total, m_inc, m_dropped)
536
 
 
556
  # conversational memory: remember this kit for follow-ups
557
  resp["kit_categories"] = categories
558
  resp["kit_budget"] = budget
559
+ same_kit = next((k for k in kits if k["brand"]), None)
560
+ mixed_kit = next((k for k in kits if not k["brand"]), None)
561
+ resp["kit_picks_same"] = same_kit["picks"] if same_kit else {}
562
+ resp["kit_picks_mixed"] = mixed_kit["picks"] if mixed_kit else {}
563
+ resp["kit_brand"] = same_kit["brand"].lower().strip() if same_kit else None
564
  return resp
565
 
566
 
 
570
  "audio": "Microphone / Audio", "sound": "Microphone / Audio",
571
  "camera": "Action Camera", "cam": "Action Camera",
572
  "action camera": "Action Camera", "gopro": "Action Camera",
573
+ "gimbal": "Gimbal / Stabilizer", "gimble": "Gimbal / Stabilizer",
574
+ "stabilizer": "Gimbal / Stabilizer", "stabiliser": "Gimbal / Stabilizer",
575
  "light": "Lighting", "lighting": "Lighting",
576
  "lens": "Lens", "tripod": "Tripod / Support", "stand": "Tripod / Support",
577
  "memory": "Memory / Storage", "card": "Memory / Storage", "sd": "Memory / Storage",
 
619
  op, payload = fu
620
  categories = list(context.get("kit_categories") or [])
621
  budget = context.get("kit_budget")
622
+ keep_same = dict(context.get("kit_picks_same") or {})
623
+ keep_mixed = dict(context.get("kit_picks_mixed") or {})
624
+ lock_brand = context.get("kit_brand")
625
  prefix = None
626
 
627
  if op == "add":
 
634
  prefix = f"Updated kit — added {', '.join(c.lower() for c in added)}"
635
  elif op == "remove":
636
  categories = [c for c in categories if c not in payload]
637
+ for c in payload:
638
+ keep_same.pop(c, None)
639
+ keep_mixed.pop(c, None)
640
  nice = ", ".join(c.lower() for c in payload)
641
  prefix = f"Updated kit — removed {nice}"
642
  elif op == "budget":
643
  budget = payload
644
  prefix = f"Rebuilt kit for {_money(budget)}"
645
+ # a budget change can legitimately require reshuffling, so don't pin
646
+ keep_same, keep_mixed, lock_brand = {}, {}, None
647
 
648
  if not categories:
649
  return _empty(query, "kit", "That would empty the kit — nothing left to build.")
650
+ return _build_kit_response(query, categories, budget, "updated", prefix=prefix,
651
+ keep_same=keep_same, keep_mixed=keep_mixed,
652
+ lock_brand=lock_brand)
653
 
654
 
655
  def _handle_use_case(query, ent):
RAG_Products/kits.py CHANGED
@@ -212,12 +212,75 @@ def build_kit(categories, budget, catalog, brand=None):
212
  return ordered, sum(_price(d) for d in ordered), included, dropped
213
 
214
 
215
- def best_single_brand_kit(categories, budget, catalog):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  """Find the brand whose products cover the most of the kit, cheapest.
217
 
 
 
 
 
 
 
 
218
  Returns (brand_display, ordered, total, included, dropped) or None if no
219
  brand covers at least 2 of the categories within budget.
220
  """
 
221
  # candidate brands = those present in these categories
222
  brand_cov = {}
223
  brand_name = {}
@@ -233,13 +296,25 @@ def best_single_brand_kit(categories, budget, catalog):
233
  # share of the kit — otherwise it's an incoherent grab-bag.
234
  min_cover = max(2, (len(categories) + 1) // 2)
235
 
236
- # rank brands by category coverage
 
 
 
 
 
 
 
 
 
 
 
237
  ranked = sorted(brand_cov.items(), key=lambda kv: len(kv[1]), reverse=True)
238
  best = None
239
  for bk, covers in ranked:
240
  if len(covers) < min_cover:
241
  break
242
- ordered, total, included, dropped = build_kit(categories, budget, catalog, brand=bk)
 
243
  if len(included) < min_cover:
244
  continue
245
  cand = (brand_name[bk], ordered, total, included, dropped)
 
212
  return ordered, sum(_price(d) for d in ordered), included, dropped
213
 
214
 
215
+ def build_kit_keep(categories, budget, catalog, keep, brand=None):
216
+ """Like build_kit, but pins `keep` (cat -> name) to the SAME product where
217
+ still possible, only solving freshly for categories not in `keep`.
218
+
219
+ This is what makes kit edits incremental: adding a gimbal to a podcast kit
220
+ must not also swap out the mic/tripod/power that were already chosen.
221
+ `keep` maps category -> product name to hold fixed across the edit.
222
+ """
223
+ def pool_for(cat):
224
+ items = [d for d in catalog
225
+ if d.metadata.get("category") == cat and d.metadata.get("price")]
226
+ if brand:
227
+ items = [d for d in items if d.metadata.get("brand_key") == brand]
228
+ return sorted(items, key=_price)
229
+
230
+ by_cat = {c: pool_for(c) for c in categories}
231
+ available = [c for c in categories if by_cat[c]]
232
+ if not available:
233
+ return [], 0, [], list(categories)
234
+
235
+ picks = {}
236
+ locked_total = 0
237
+ new_cats = []
238
+ for c in available:
239
+ kept_name = keep.get(c)
240
+ found = None
241
+ if kept_name:
242
+ found = next((d for d in by_cat[c] if d.metadata.get("name") == kept_name), None)
243
+ if found:
244
+ picks[c] = found
245
+ locked_total += _price(found)
246
+ else:
247
+ new_cats.append(c)
248
+
249
+ if not new_cats:
250
+ ordered = [picks[c] for c in categories if c in picks]
251
+ included = [c for c in categories if c in picks]
252
+ dropped = [c for c in categories if c not in picks]
253
+ return ordered, sum(_price(d) for d in ordered), included, dropped
254
+
255
+ # solve only the new categories with whatever budget remains
256
+ remaining = (budget - locked_total) if budget else None
257
+ if remaining is not None and remaining < 0:
258
+ remaining = 0
259
+ new_ordered, _, new_included, new_dropped = build_kit(
260
+ new_cats, remaining, catalog, brand=brand)
261
+ for d in new_ordered:
262
+ picks[d.metadata.get("category")] = d
263
+
264
+ ordered = [picks[c] for c in categories if c in picks]
265
+ included = [c for c in categories if c in picks]
266
+ dropped = [c for c in categories if c not in picks]
267
+ return ordered, sum(_price(d) for d in ordered), included, dropped
268
+
269
+
270
+ def best_single_brand_kit(categories, budget, catalog, keep=None, lock_brand=None):
271
  """Find the brand whose products cover the most of the kit, cheapest.
272
 
273
+ `keep` (cat -> name) pins already-chosen items so an edit doesn't reshuffle
274
+ the rest of the bundle. `lock_brand` keeps the bundle on the SAME brand
275
+ across an edit (e.g. adding a gimbal to a Digitek podcast kit must not
276
+ swap the whole bundle to a brand that happens to sell gimbals) — if that
277
+ brand can't cover a newly added category, the category is just left
278
+ uncovered for this bundle rather than abandoning the brand.
279
+
280
  Returns (brand_display, ordered, total, included, dropped) or None if no
281
  brand covers at least 2 of the categories within budget.
282
  """
283
+ keep = keep or {}
284
  # candidate brands = those present in these categories
285
  brand_cov = {}
286
  brand_name = {}
 
296
  # share of the kit — otherwise it's an incoherent grab-bag.
297
  min_cover = max(2, (len(categories) + 1) // 2)
298
 
299
+ if lock_brand:
300
+ # stay on the same brand regardless of new-category coverage; only
301
+ # the categories that brand actually stocks get filled in.
302
+ covers = brand_cov.get(lock_brand, set())
303
+ if not covers:
304
+ return None
305
+ ordered, total, included, dropped = build_kit_keep(
306
+ categories, budget, catalog, keep, brand=lock_brand)
307
+ if not included:
308
+ return None
309
+ return (brand_name.get(lock_brand, lock_brand), ordered, total, included, dropped)
310
+
311
  ranked = sorted(brand_cov.items(), key=lambda kv: len(kv[1]), reverse=True)
312
  best = None
313
  for bk, covers in ranked:
314
  if len(covers) < min_cover:
315
  break
316
+ ordered, total, included, dropped = build_kit_keep(
317
+ categories, budget, catalog, keep, brand=bk)
318
  if len(included) < min_cover:
319
  continue
320
  cand = (brand_name[bk], ordered, total, included, dropped)