murtaza-2007 commited on
Commit ·
c10898c
1
Parent(s): c00c685
LLM-first kit planning: read use-case intent and map to in-stock categories instead of defaulting novel requests to vlogging
Browse files- RAG_Products/kits.py +92 -32
RAG_Products/kits.py
CHANGED
|
@@ -60,16 +60,41 @@ _KIT_TRIGGERS = [
|
|
| 60 |
]
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
def detect_kit(query, budget=None):
|
| 64 |
"""Return (kit_name, [categories]) if the query asks for a setup, else None.
|
| 65 |
|
| 66 |
Requires an explicit kit word (kit/setup/bundle/gear/rig/...) OR a budget
|
| 67 |
alongside a kit trigger — so "best mic for an interview" is NOT a kit, while
|
| 68 |
"interview setup" and "vlogging under 50k" are.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
"""
|
| 70 |
q = query.lower()
|
| 71 |
-
has_kit_word = bool(
|
| 72 |
-
r"\b(kit|set ?up|bundle|package|gear|rig|complete|everything (i|you) need|build me)\b", q))
|
| 73 |
matched = None
|
| 74 |
for name, triggers in _KIT_TRIGGERS:
|
| 75 |
if any(t in q for t in triggers):
|
|
@@ -78,19 +103,23 @@ def detect_kit(query, budget=None):
|
|
| 78 |
if matched and (has_kit_word or budget):
|
| 79 |
return matched, KIT_TEMPLATES[matched]
|
| 80 |
if has_kit_word:
|
| 81 |
-
|
| 82 |
-
return
|
| 83 |
return None
|
| 84 |
|
| 85 |
|
| 86 |
_LLM_PLAN_CACHE = {}
|
| 87 |
|
| 88 |
|
| 89 |
-
def plan_categories_llm(query):
|
| 90 |
-
"""Use the LLM to
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
"""
|
| 95 |
from RAG_Products.models import llm_available, llm_complete
|
| 96 |
|
|
@@ -99,18 +128,34 @@ def plan_categories_llm(query):
|
|
| 99 |
key = query.lower().strip()
|
| 100 |
if key in _LLM_PLAN_CACHE:
|
| 101 |
return _LLM_PLAN_CACHE[key]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
prompt = (
|
| 103 |
-
"You
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
"Respond with ONLY a JSON array of category strings, nothing else."
|
| 108 |
)
|
| 109 |
try:
|
| 110 |
raw = llm_complete(prompt)
|
| 111 |
m = re.search(r"\[.*\]", raw, flags=re.DOTALL)
|
| 112 |
cats = json.loads(m.group(0) if m else raw)
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
result = valid or None
|
| 115 |
except Exception:
|
| 116 |
result = None
|
|
@@ -118,34 +163,49 @@ def plan_categories_llm(query):
|
|
| 118 |
return result
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
def plan_categories(query, budget=None, use_llm=True, stock=None):
|
| 122 |
-
"""Decide
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
"""
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 136 |
|
|
|
|
| 137 |
if use_llm:
|
| 138 |
-
cats = plan_categories_llm(query)
|
| 139 |
if cats:
|
| 140 |
-
|
| 141 |
-
cats = [c for c in cats if c in stock]
|
| 142 |
if cats:
|
| 143 |
return cats, "llm"
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
cats = [c for c in cats if c in stock]
|
| 148 |
-
return cats, "default"
|
| 149 |
|
| 150 |
|
| 151 |
def _price(doc):
|
|
|
|
| 60 |
]
|
| 61 |
|
| 62 |
|
| 63 |
+
_KIT_WORD_RE = re.compile(
|
| 64 |
+
r"\b(kit|set ?up|bundle|package|gear|rig|complete|everything (i|you) need|build me)\b")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def derive_kit_name(query):
|
| 68 |
+
"""A human label for the kit header, read from the request.
|
| 69 |
+
|
| 70 |
+
"i need a short film making setup" -> "short film making"
|
| 71 |
+
"wildlife professional photography setup" -> "wildlife professional photography"
|
| 72 |
+
Falls back to "custom" if nothing meaningful is left.
|
| 73 |
+
"""
|
| 74 |
+
q = query.lower()
|
| 75 |
+
# drop filler + the kit word itself, keep the descriptive middle
|
| 76 |
+
q = re.sub(r"\b(i|need|want|a|an|the|for|me|please|some|my|get|give|make|"
|
| 77 |
+
r"build|looking|recommend|suggest|good|best|professional|pro)\b", " ", q)
|
| 78 |
+
q = _KIT_WORD_RE.sub(" ", q)
|
| 79 |
+
q = re.sub(r"\b(under|below|upto|up to|around|within)\b.*$", " ", q) # strip budget tail
|
| 80 |
+
q = re.sub(r"[^a-z\s]", " ", q)
|
| 81 |
+
name = " ".join(q.split()).strip()
|
| 82 |
+
return name or "custom"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
def detect_kit(query, budget=None):
|
| 86 |
"""Return (kit_name, [categories]) if the query asks for a setup, else None.
|
| 87 |
|
| 88 |
Requires an explicit kit word (kit/setup/bundle/gear/rig/...) OR a budget
|
| 89 |
alongside a kit trigger — so "best mic for an interview" is NOT a kit, while
|
| 90 |
"interview setup" and "vlogging under 50k" are.
|
| 91 |
+
|
| 92 |
+
The returned categories are only a hint; the real planning happens in
|
| 93 |
+
plan_categories (LLM-first). When no known type is named, kit_name is read
|
| 94 |
+
from the request so the header reflects what the user actually asked for.
|
| 95 |
"""
|
| 96 |
q = query.lower()
|
| 97 |
+
has_kit_word = bool(_KIT_WORD_RE.search(q))
|
|
|
|
| 98 |
matched = None
|
| 99 |
for name, triggers in _KIT_TRIGGERS:
|
| 100 |
if any(t in q for t in triggers):
|
|
|
|
| 103 |
if matched and (has_kit_word or budget):
|
| 104 |
return matched, KIT_TEMPLATES[matched]
|
| 105 |
if has_kit_word:
|
| 106 |
+
# unknown kit type -> let plan_categories (LLM) decide; label from query
|
| 107 |
+
return derive_kit_name(query), KIT_TEMPLATES["vlogging"]
|
| 108 |
return None
|
| 109 |
|
| 110 |
|
| 111 |
_LLM_PLAN_CACHE = {}
|
| 112 |
|
| 113 |
|
| 114 |
+
def plan_categories_llm(query, stock=None):
|
| 115 |
+
"""Use the LLM to read the user's request and pick kit categories.
|
| 116 |
|
| 117 |
+
The LLM understands the *intent* (e.g. "short film", "wildlife photography",
|
| 118 |
+
"live streaming") and maps it onto the store's real, in-stock categories.
|
| 119 |
+
It chooses ONLY categories — never products or prices. Cached per query.
|
| 120 |
+
|
| 121 |
+
`stock` is the set of categories that actually have inventory; we hand it to
|
| 122 |
+
the LLM so it never plans around gear we don't carry.
|
| 123 |
"""
|
| 124 |
from RAG_Products.models import llm_available, llm_complete
|
| 125 |
|
|
|
|
| 128 |
key = query.lower().strip()
|
| 129 |
if key in _LLM_PLAN_CACHE:
|
| 130 |
return _LLM_PLAN_CACHE[key]
|
| 131 |
+
|
| 132 |
+
choices = sorted(stock) if stock else AVAILABLE_CATEGORIES
|
| 133 |
+
# a couple of worked examples teach the model to discriminate use-cases
|
| 134 |
+
# instead of defaulting everything to a generic vlogging bundle.
|
| 135 |
prompt = (
|
| 136 |
+
"You are an expert kit planner for a photography & videography store.\n"
|
| 137 |
+
"Read what the customer wants to shoot and decide which equipment "
|
| 138 |
+
"categories their kit needs. Different jobs need different gear:\n"
|
| 139 |
+
" - a short film needs a camera, audio, lighting, support and storage\n"
|
| 140 |
+
" - wildlife photography needs a long lens, a sturdy tripod, storage, power\n"
|
| 141 |
+
" - live streaming needs lighting, audio, a capture/storage path\n"
|
| 142 |
+
" - a podcast is audio-first with stands and power\n"
|
| 143 |
+
f"\nCustomer request: \"{query}\"\n\n"
|
| 144 |
+
f"Pick ONLY from these in-stock categories: {choices}.\n"
|
| 145 |
+
"Choose the 3-6 most relevant, most-important first. Do not invent "
|
| 146 |
+
"categories outside the list.\n"
|
| 147 |
"Respond with ONLY a JSON array of category strings, nothing else."
|
| 148 |
)
|
| 149 |
try:
|
| 150 |
raw = llm_complete(prompt)
|
| 151 |
m = re.search(r"\[.*\]", raw, flags=re.DOTALL)
|
| 152 |
cats = json.loads(m.group(0) if m else raw)
|
| 153 |
+
# keep order, validate against the real category vocabulary
|
| 154 |
+
seen, valid = set(), []
|
| 155 |
+
for c in cats:
|
| 156 |
+
if c in AVAILABLE_CATEGORIES and c not in seen:
|
| 157 |
+
seen.add(c)
|
| 158 |
+
valid.append(c)
|
| 159 |
result = valid or None
|
| 160 |
except Exception:
|
| 161 |
result = None
|
|
|
|
| 163 |
return result
|
| 164 |
|
| 165 |
|
| 166 |
+
def _exact_template(query):
|
| 167 |
+
"""Return (name, categories) only when the query NAMES a known kit type
|
| 168 |
+
(podcast, vlogging, studio, ...). A bare "setup" with no recognised type
|
| 169 |
+
does NOT match here — that goes to the LLM so it can read the real intent.
|
| 170 |
+
"""
|
| 171 |
+
q = query.lower()
|
| 172 |
+
for name, triggers in _KIT_TRIGGERS:
|
| 173 |
+
if any(t in q for t in triggers):
|
| 174 |
+
return name, KIT_TEMPLATES[name]
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
def plan_categories(query, budget=None, use_llm=True, stock=None):
|
| 179 |
+
"""Decide which categories a kit needs.
|
| 180 |
|
| 181 |
+
LLM-FIRST: the model reads the request, understands the use-case, and maps
|
| 182 |
+
it onto our in-stock categories — so "short film", "wildlife photography"
|
| 183 |
+
and "live streaming" each get the right gear instead of a generic vlogging
|
| 184 |
+
bundle. Order of precedence:
|
| 185 |
+
|
| 186 |
+
1. A request that NAMES a known kit type (podcast/vlogging/studio/...) uses
|
| 187 |
+
that curated template — instant and high quality.
|
| 188 |
+
2. Otherwise the LLM plans categories from the use-case.
|
| 189 |
+
3. If the LLM is unavailable or fails, fall back to the vlogging template.
|
| 190 |
"""
|
| 191 |
+
def _filt(cats):
|
| 192 |
+
return [c for c in cats if c in stock] if stock else list(cats)
|
| 193 |
+
|
| 194 |
+
# 1. named, curated kit type
|
| 195 |
+
exact = _exact_template(query)
|
| 196 |
+
if exact:
|
| 197 |
+
return _filt(exact[1]), "template"
|
| 198 |
|
| 199 |
+
# 2. LLM reads the intent and picks in-stock categories
|
| 200 |
if use_llm:
|
| 201 |
+
cats = plan_categories_llm(query, stock=stock)
|
| 202 |
if cats:
|
| 203 |
+
cats = _filt(cats)
|
|
|
|
| 204 |
if cats:
|
| 205 |
return cats, "llm"
|
| 206 |
|
| 207 |
+
# 3. last-resort default
|
| 208 |
+
return _filt(KIT_TEMPLATES["vlogging"]), "default"
|
|
|
|
|
|
|
| 209 |
|
| 210 |
|
| 211 |
def _price(doc):
|