-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathorder_classifier.py
More file actions
270 lines (228 loc) · 10.7 KB
/
Copy pathorder_classifier.py
File metadata and controls
270 lines (228 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""
Order classifier using sentence-transformers (paraphrase-MiniLM-L3-v2, ~17MB).
Classifies a Swiggy order's items into:
- "include" : groceries / produce / dairy / poultry / meat / staples
- "exclude" : non-food (detergent, stationery, cosmetics, …)
- "ask" : ambiguous or no items detected → prompt the user
Performance note:
Anchor embeddings (INCLUDE_ANCHORS + EXCLUDE_ANCHORS + feedback) are computed
once per process and cached in module-level variables (_inc_vecs, _exc_vecs).
Feedback is also read from disk once and cached (_feedback_cache).
This means classifying 50 orders costs 50 item-encodes instead of
50 item-encodes + 50 anchor-encodes — a significant speedup for batch runs.
Usage:
from order_classifier import classify_order
decision, confidence = classify_order(["Paneer", "Brinjal", "Tomato"])
# → ("include", 0.74)
"""
from __future__ import annotations
import json
from pathlib import Path
_model = None # lazy-loaded on first call to _get_model()
# ── Embedding cache ─────────────────────────────────────────────────────────────
# Anchor vectors are expensive to compute (one forward pass per phrase).
# We cache them at module level so they are computed only once per process,
# regardless of how many orders are classified in a single run.
_inc_vecs = None # encoded INCLUDE_ANCHORS + feedback["include"]
_exc_vecs = None # encoded EXCLUDE_ANCHORS + feedback["exclude"]
_feedback_cache = None # feedback dict, read from disk once per process
FEEDBACK_FILE = Path(__file__).parent / "classifier_feedback.json"
def _load_feedback() -> dict:
"""Read classifier_feedback.json from disk. Cached after first call."""
global _feedback_cache
if _feedback_cache is None:
if FEEDBACK_FILE.exists():
_feedback_cache = json.loads(FEEDBACK_FILE.read_text())
else:
_feedback_cache = {"include": [], "exclude": []}
return _feedback_cache
def _save_feedback(decision: str, items: list[str]):
"""
Persist new include/exclude items to classifier_feedback.json.
Also invalidates the embedding cache so the next classify_order call
re-encodes anchors with the newly learned items included.
"""
global _feedback_cache, _inc_vecs, _exc_vecs
data = _load_feedback()
existing = set(i.lower() for i in data[decision])
for item in items:
if item.lower() not in existing:
data[decision].append(item)
existing.add(item.lower())
FEEDBACK_FILE.write_text(json.dumps(data, indent=2))
# Invalidate caches so the next call picks up the new feedback items
_feedback_cache = None
_inc_vecs = None
_exc_vecs = None
# ── Anchor phrases ─────────────────────────────────────────────────────────────
# Each anchor is a short descriptive phrase for that category.
# The more specific and varied, the better the similarity scores.
INCLUDE_ANCHORS = [
# Kitchen — food and cooking
"fresh vegetables brinjal tomato onion potato carrot capsicum",
"leafy greens spinach methi coriander mint cabbage lettuce",
"dairy paneer cottage cheese curd yogurt butter ghee cream",
"eggs poultry chicken mutton fish prawns seafood",
"rice wheat flour lentils dal pulses chickpeas rajma",
"cooking oil mustard oil sunflower oil groundnut oil",
"spices masala turmeric cumin coriander pepper garam masala",
"bread biscuits snacks chips namkeen dry fruits nuts",
"frozen vegetables peas corn ready to cook",
"mushroom baby corn broccoli zucchini exotic vegetables",
# Shared cleaning — flat/kitchen level
"detergent washing powder liquid laundry cleaner fabric softener",
"floor cleaner disinfectant bathroom cleaner surface wipes",
"dish wash bar liquid kitchen cleaner utensil scrubber",
"mosquito repellent insecticide pest control",
"garbage bag trash liner dustbin liner",
]
EXCLUDE_ANCHORS = [
# Personal care — individual use, not shared with roommates
"hand wash refill soap dispenser liquid hand soap",
"shampoo conditioner hair oil hair serum personal care",
"face wash body lotion moisturiser skincare cosmetics beauty",
"toothpaste toothbrush mouthwash dental oral care",
"deodorant perfume cologne body spray",
"sanitary pad tampon intimate hygiene personal",
"tissue paper toilet paper napkin",
# Fruits (personal snacking, not shared cooking ingredients)
"fruits mango banana apple orange grapes watermelon papaya dragon fruit mandarin",
"imported exotic fruit fresh cut fruit fruit basket",
# Personal beverages & breakfast — individual, not shared cooking
"milk lactose free milk almond milk oat milk soy milk",
"muesli oats cornflakes chocos granola breakfast cereal porridge kellogs",
"juice health drink energy drink protein shake smoothie amla juice",
# Stationery
"pen pencil notebook pages stationery office school supplies",
"book novel textbook magazine",
# Medicine
"medicine tablet capsule paracetamol syrup pharmaceutical vitamin supplement",
# Electronics
"charger cable phone accessories electronics gadget earphone",
# Clothing & fashion
"bag backpack clothing apparel fashion accessories footwear",
]
# ── Confidence thresholds ──────────────────────────────────────────────────────
HIGH_CONFIDENCE = 0.38 # auto-decide above this
MIN_MARGIN = 0.06 # include must beat exclude by this margin (and vice versa)
def _get_model():
global _model
if _model is None:
try:
from sentence_transformers import SentenceTransformer
except ImportError:
raise ImportError(
"sentence-transformers not installed. "
"Run: pip install sentence-transformers"
)
print(" Loading classifier model (first run only)…", flush=True)
_model = SentenceTransformer("paraphrase-MiniLM-L3-v2")
return _model
def _get_anchor_vecs():
"""
Return (inc_vecs, exc_vecs) — the encoded anchor tensors.
These are computed once and cached. The cache is invalidated only when
_save_feedback() is called (i.e. the user answers a y/n prompt), so new
feedback items are picked up on the very next classify_order call.
"""
global _inc_vecs, _exc_vecs
if _inc_vecs is None or _exc_vecs is None:
model = _get_model()
feedback = _load_feedback()
inc_anchors = INCLUDE_ANCHORS + feedback.get("include", [])
exc_anchors = EXCLUDE_ANCHORS + feedback.get("exclude", [])
print(" Computing anchor embeddings (once per run)…", flush=True)
_inc_vecs = model.encode(inc_anchors, convert_to_tensor=True)
_exc_vecs = model.encode(exc_anchors, convert_to_tensor=True)
return _inc_vecs, _exc_vecs
def classify_order(items: list[str]) -> tuple[str, float]:
"""
Classify an order's items.
For each item, computes cosine similarity against all INCLUDE and EXCLUDE
anchor phrases and takes the max (best-matching anchor). The order's final
score is the average across all items.
Anchor vectors are pre-computed and cached — only the item vectors are
encoded fresh on each call, which is fast (typically 1–5 items per order).
Returns
-------
(decision, confidence)
decision : "include" | "exclude" | "ask"
confidence : 0.0–1.0 (average cosine similarity to winning category)
"""
if not items:
return "ask", 0.0
from sentence_transformers import util
model = _get_model()
inc_vecs, exc_vecs = _get_anchor_vecs()
# Only the per-order item vectors are computed fresh each call
item_vecs = model.encode(items, convert_to_tensor=True)
inc_scores, exc_scores = [], []
for vec in item_vecs:
inc_scores.append(float(util.cos_sim(vec, inc_vecs).max()))
exc_scores.append(float(util.cos_sim(vec, exc_vecs).max()))
avg_inc = sum(inc_scores) / len(inc_scores)
avg_exc = sum(exc_scores) / len(exc_scores)
margin = avg_inc - avg_exc
if avg_inc >= HIGH_CONFIDENCE and margin >= MIN_MARGIN:
return "include", round(avg_inc, 3)
if avg_exc >= HIGH_CONFIDENCE and (-margin) >= MIN_MARGIN:
return "exclude", round(avg_exc, 3)
return "ask", round(max(avg_inc, avg_exc), 3)
def classify_and_filter(
orders: list[dict],
verbose: bool = True,
) -> list[dict]:
"""
Run classify_order on each order.
- "include" → kept automatically
- "exclude" → dropped automatically
- "ask" → user is prompted interactively
Returns the filtered list of orders to sync.
"""
if verbose:
print(f"\nClassifying {len(orders)} order(s)…\n")
kept, skipped, to_ask = [], [], []
for o in orders:
items = o.get("items", [])
decision, conf = classify_order(items)
items_str = ", ".join(f'"{i}"' for i in items) if items else "no items detected"
tag = f"{o['date']} ₹{o['amount']:.2f} [{items_str}]"
if decision == "include":
if verbose:
print(f" ✓ include ({conf:.2f}) {tag}")
kept.append(o)
elif decision == "exclude":
if verbose:
print(f" ✗ exclude ({conf:.2f}) {tag}")
skipped.append(o)
else:
to_ask.append((o, conf, tag))
# Interactive prompts for ambiguous orders
if to_ask:
if verbose:
print()
for o, conf, tag in to_ask:
print(f" ? unsure ({conf:.2f}) {tag}")
while True:
ans = input(" Include this order? [y/n]: ").strip().lower()
if ans in ("y", "yes"):
kept.append(o)
_save_feedback("include", o.get("items", []))
break
elif ans in ("n", "no"):
skipped.append(o)
_save_feedback("exclude", o.get("items", []))
break
else:
print(" Please enter y or n.")
if verbose:
print(f"\n → {len(kept)} included, {len(skipped)} excluded\n")
if skipped:
print(" Excluded orders:")
print(" " + "─" * 60)
for o in skipped:
items = o.get("items", [])
items_str = ", ".join(f'"{i}"' for i in items) if items else "no items detected"
print(f" {o['date']} ₹{o['amount']:.2f} [{items_str}]")
print()
return kept