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- RAG_Products/chat.py +34 -7
- 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
| 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", "
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
| 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)
|