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
Files changed (1) hide show
  1. 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(re.search(
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
- name = matched or "vlogging"
82
- return name, KIT_TEMPLATES[name]
83
  return None
84
 
85
 
86
  _LLM_PLAN_CACHE = {}
87
 
88
 
89
- def plan_categories_llm(query):
90
- """Use the LLM to pick kit categories from the user's request.
91
 
92
- Cached per query so a repeat request is instant. Returns a validated list
93
- of categories or None. The LLM chooses ONLY categories never prices.
 
 
 
 
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 plan equipment kits for a photography & videography store.\n"
104
- f"Choose the categories needed for this request: \"{query}\".\n"
105
- f"Pick ONLY from this exact list: {AVAILABLE_CATEGORIES}.\n"
106
- "Order them most-important first, at most 6.\n"
 
 
 
 
 
 
 
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
- valid = [c for c in cats if c in AVAILABLE_CATEGORIES]
 
 
 
 
 
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 kit categories.
123
 
124
- TEMPLATE-FIRST: a recognised kit type (vlogging, podcast, studio, ...) uses
125
- its curated template instantlyfaster and higher quality than the LLM,
126
- which tends to pick aspirational-but-unstocked categories. The LLM runs only
127
- for novel requests with no matching template. `stock` (a set of categories
128
- that actually have inventory) filters out empty picks.
 
 
 
 
129
  """
130
- kit = detect_kit(query, budget)
131
- if kit:
132
- cats = kit[1]
133
- if stock:
134
- cats = [c for c in cats if c in stock]
135
- return cats, "template"
 
136
 
 
137
  if use_llm:
138
- cats = plan_categories_llm(query)
139
  if cats:
140
- if stock:
141
- cats = [c for c in cats if c in stock]
142
  if cats:
143
  return cats, "llm"
144
 
145
- cats = KIT_TEMPLATES["vlogging"]
146
- if stock:
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):