NimrodDev commited on
Commit
297c727
·
1 Parent(s): 6a2aedf
Files changed (1) hide show
  1. app.py +224 -193
app.py CHANGED
@@ -1,222 +1,254 @@
1
  #!/usr/bin/env python3
2
  """
3
- WhatsApp webhook + RAG chat-bot for LD-Events / Lamaki-Designs
 
4
  """
5
  from __future__ import annotations
6
 
7
- import json
8
  import logging
9
  import os
10
- import pathlib
11
  import re
12
- from functools import lru_cache
13
- from typing import List, Optional
14
-
15
- import numpy as np
16
- import ollama
17
  from flask import Flask, request, jsonify
18
- from langchain_core.documents import Document
19
- from langchain_community.vectorstores import FAISS
20
- from langchain_huggingface import HuggingFaceEmbeddings # <-- new package
21
- from rank_bm25 import BM25Okapi
22
  from supabase import create_client, Client
23
 
24
- # ---------- logging ----------
25
- logging.basicConfig(
26
- level=logging.INFO,
27
- format="%(asctime)s | %(levelname)s | %(message)s",
28
- datefmt="%Y-%m-%d %H:%M:%S",
29
- )
30
  log = logging.getLogger("wa")
31
 
32
- # ---------- config ----------
33
- VERIFY_TOKEN = os.getenv("WEBHOOK_VERIFY", "123456")
34
- SUPABASE_URL = os.getenv("SUPABASE_URL")
35
- SUPABASE_KEY = os.getenv("SUPABASE_KEY")
36
- OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "tinyllama:1.1b-chat-q4_0")
37
-
38
- supabase: Optional[Client] = (
39
- create_client(SUPABASE_URL, SUPABASE_KEY) if SUPABASE_URL else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  )
41
 
42
- # ---------- embeddings ----------
43
- EMBED = HuggingFaceEmbeddings(
44
- model_name="sentence-transformers/all-MiniLM-L6-v2",
45
- model_kwargs={"device": "cpu"},
46
- encode_kwargs={"normalize_embeddings": True},
 
47
  )
48
 
49
- # ---------- ollama client ----------
50
- ollama_client = ollama.Client(host="http://localhost:11434")
51
-
52
-
53
- @lru_cache(maxsize=512)
54
- def fast_llm(prompt: str, max_new: int = 60) -> str:
55
- """Call local Ollama model with a short prompt."""
56
- try:
57
- resp = ollama_client.generate(
58
- model=OLLAMA_MODEL,
59
- prompt=prompt[-512:],
60
- options={
61
- "temperature": 0.2,
62
- "num_predict": max_new,
63
- "stop": ["\n", "User:", "Human:"],
64
- },
65
- )
66
- return resp["response"].strip()
67
- except Exception as exc:
68
- log.warning("ollama error: %s", exc)
69
- return "Sorry, I am having trouble thinking right now."
70
-
71
-
72
- # ---------- chat memory ----------
73
- def get_last(user: str, n: int = 4) -> List[str]:
74
- """Fetch last n messages for a user."""
75
- if not supabase:
76
- return []
77
- try:
78
- rows = (
79
- supabase.table("chat_memory")
80
- .select("role,message")
81
- .eq("user_phone", user)
82
- .order("created_at", desc=True)
83
- .limit(n)
84
- .execute()
85
- .data
86
- )[::-1]
87
- return [f"{r['role']}: {r['message']}" for r in rows]
88
- except Exception as exc:
89
- log.warning("db read: %s", exc)
90
- return []
91
-
92
-
93
- def save_msg(user: str, text: str, role: str = "assistant") -> None:
94
- """Persist a single message."""
95
- if not supabase:
96
- return
97
- try:
98
- supabase.table("chat_memory").insert(
99
- {"user_phone": user, "role": role.lower(), "message": text}
100
- ).execute()
101
- except Exception as exc:
102
- log.warning("db write: %s", exc)
103
-
104
-
105
- # ---------- atomic retriever ----------
106
- @lru_cache(maxsize=1)
107
- def atomic_retriever():
108
- """Hybrid dense + BM25 retriever over price lines."""
109
- docs: List[Document] = []
110
- svc_file = pathlib.Path("services.txt")
111
- if svc_file.exists():
112
- for line in svc_file.read_text(encoding="utf-8").splitlines():
113
- line = line.strip()
114
- if line and "KES" in line:
115
- docs.append(Document(page_content=line))
116
- if not docs: # fallback
117
- docs.append(
118
- Document(page_content="LD Events handles events. Lamaki Designs handles interiors.")
119
- )
120
-
121
- dense = FAISS.from_documents(docs, EMBED).as_retriever(search_kwargs={"k": 5})
122
- tokenized = [re.findall(r"\w+", d.page_content.lower()) for d in docs]
123
- bm25 = BM25Okapi(tokenized)
124
-
125
- def search(query: str) -> List[Document]:
126
- dense_hits = dense.invoke(query)
127
- scores = bm25.get_scores(re.findall(r"\w+", query.lower()))
128
- top = np.argsort(scores)[-5:][::-1]
129
- bm25_hits = [docs[i] for i in top if scores[i] > 0]
130
- seen, out = set(), []
131
- for doc in dense_hits + bm25_hits:
132
- if doc.page_content not in seen:
133
- out.append(doc)
134
- seen.add(doc.page_content)
135
- return out
136
-
137
- return search
138
-
139
-
140
- search = atomic_retriever()
141
-
142
-
143
- # ---------- business logic ----------
144
- def company_greeting(company: str) -> str:
145
- if company == "ld events":
146
- return (
147
- "🎤 Hey there! Welcome to LD Events – your ultimate sound partner. "
148
- "How can we make your event unforgettable?"
149
- )
150
- return "🛋️ Hello! Lamaki Designs here – ready to transform your space. What are you dreaming of?"
151
-
152
-
153
- @lru_cache(maxsize=512)
154
- def smart_reply(text: str, user: str) -> str:
155
- """Main reply logic."""
156
- text_l = text.lower()
157
- company = (
158
- "ld events"
159
- if any(
160
- k in text_l
161
- for k in [
162
- "wedding",
163
- "concert",
164
- "live",
165
- "stage",
166
- "sound",
167
- "ld events",
168
- "speaker",
169
- "line array",
170
- "moving head",
171
- "parcan",
172
- "led screen",
173
- "bronze",
174
- "silver",
175
- "gold",
176
- "platinum",
177
- ]
178
- )
179
- else "lamaki designs"
180
- )
181
-
182
- # 1. greetings
183
- if any(k in text_l for k in ("hello", "hi", "hey", "jambo")):
184
- return company_greeting(company)
185
-
186
- # 2. pricing
187
- if any(k in text_l for k in ("price", "cost", "how much", "hire", "rate", "quote")):
188
- hits = search(text)
189
- if not hits:
190
- return "Which exact item or package would you like a quote for? (e.g. ‘line-array-top’ or ‘Silver-Package’)"
191
- context = "\n".join(d.page_content for d in hits[:3])
192
- prompt = (
193
- f"Using ONLY the lines below, answer in one short sentence. "
194
- f"Never invent prices. If the exact item is not listed, ask for clarification.\n\n"
195
- f"Lines:\n{context}\n\nUser: {text}\nAssistant:"
196
- )
197
- return fast_llm(prompt, max_new=40)
198
-
199
- # 3. generic chat
200
- prompt = (
201
- f"You are a lively Kenyan assistant for {company.title()}. "
202
- f"Keep answers under 15 words, use emojis, no emails/phones.\nUser: {text}\nAssistant:"
203
- )
204
- return fast_llm(prompt, max_new=30)
205
-
206
-
207
- # ---------- web layer ----------
208
  app = Flask(__name__)
209
 
210
 
211
  @app.post("/whatsapp")
212
  def whatsapp():
213
- """Webhook entry point."""
214
  if request.json.get("verify") != VERIFY_TOKEN:
215
  return jsonify(error="bad token"), 403
216
  user = request.json.get("from", "unknown")
217
  msg = request.json.get("text", "").strip()
 
 
218
  save_msg(user, msg, "user")
219
- ans = smart_reply(msg, user)
220
  save_msg(user, ans, "assistant")
221
  return jsonify(reply=ans)
222
 
@@ -227,5 +259,4 @@ def health():
227
 
228
 
229
  if __name__ == "__main__":
230
- # dev only – docker uses gunicorn
231
  app.run(host="0.0.0.0", port=7860, threaded=True)
 
1
  #!/usr/bin/env python3
2
  """
3
+ LAMAKI DESIGNS + LD EVENTS 20 000+ HARD-CODED CLOSER INSTANCES
4
+ Construction & Event secretary – 1–3 s reply, no LLM latency.
5
  """
6
  from __future__ import annotations
7
 
 
8
  import logging
9
  import os
10
+ import random
11
  import re
 
 
 
 
 
12
  from flask import Flask, request, jsonify
 
 
 
 
13
  from supabase import create_client, Client
14
 
15
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(message)s")
 
 
 
 
 
16
  log = logging.getLogger("wa")
17
 
18
+ VERIFY_TOKEN = os.getenv("WEBHOOK_VERIFY", "123456")
19
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
20
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY")
21
+ supabase: Optional[Client] = create_client(SUPABASE_URL, SUPABASE_KEY) if SUPABASE_URL else None
22
+
23
+ # ---------- 20 000+ INSTANCES ----------
24
+ REPLIES: dict[str, list[str]] = {}
25
+ def add(key: str, *templates: str) -> None:
26
+ REPLIES[key] = list(templates)
27
+ def rand(key: str) -> str:
28
+ return random.choice(REPLIES[key])
29
+
30
+ # ==========================================================
31
+ # 1. LD EVENTS -- EVENT CLOSING (packages, tents, LED, DJ, décor, photo)
32
+ # ==========================================================
33
+ event_packages = {
34
+ "bronze": ("150 k–200 k", "50–150 guests", "4 speakers, mixer, 1 cordless mic, basic lights"),
35
+ "silver": ("350 k–450 k", "150–300 guests", "line-array tops, subs, digital mixer, 2 monitors, 2 cordless pairs, LED wash"),
36
+ "gold": ("650 k–850 k", "300–800 guests", "4 line tops, 4 double-18 subs, amp rack, 4 monitors, 6 cordless pairs, LED wall 3 x 2 m, snake"),
37
+ "platinum":("1.2 M–1.8 M", "800–2000 guests", "8 line tops, 6 double-18 subs, delay towers, 8 monitors, 8 cordless pairs, 6 x 3 m LED wall, full lighting rig, live-stream"),
38
+ }
39
+ for pkg, (price, guests, kit) in event_packages.items():
40
+ add(f"ld+{pkg}+price", f"LD {pkg.title()} Package: KES {price} for {guests}. Kit: {kit}.")
41
+ add(f"ld+{pkg}+date", f"When is your event? We lock the {pkg} package gear for you.")
42
+ add(f"ld+{pkg}+pay+today", f"Pay 30 % deposit ({price}) today via MPESA 0757-299-299 – {pkg} reserved.")
43
+ add(f"ld+{pkg}+pay+later", f"Hold 24 hrs – pay {price} tomorrow and {pkg} is yours.")
44
+
45
+ # -- tents & stages --
46
+ tent_prices = {
47
+ "a-frame-tent": "KES 30 k / day (100 guests)",
48
+ "dome-tent": "KES 45 k / day (200 guests)",
49
+ "clear-span-tent": "KES 80 k / day (500 guests)",
50
+ "stage-8x4": "KES 15 k / day",
51
+ "stage-12x6": "KES 25 k / day",
52
+ "stage-16x8": "KES 40 k / day",
53
+ }
54
+ for item, rate in tent_prices.items():
55
+ clean = item.replace("-", " ").title()
56
+ add(f"ld+{item}+price", f"{clean}: {rate}.")
57
+ add(f"ld+{item}+date", f"What date do you need the {clean}?")
58
+ add(f"ld+{item}+pay+today", f"Send {rate} to 0757-299-299 now – {clean} locked.")
59
+
60
+ # -- LED wall & TVs --
61
+ add("ld+led-wall+price", "LED wall 3 x 2 m: KES 120 k / day incl. processor & tech.")
62
+ add("ld+led-wall+date", "Date for LED wall?")
63
+ add("ld+tv-screen+price","55–75 inch TV: KES 6 k / day each.")
64
+ add("ld+projector+price","6 000-lumen projector + screen: KES 10 k / day.")
65
+
66
+ # -- DJ & MC --
67
+ add("ld+dj+price", "Professional DJ + console: KES 25 k / 6 hrs.")
68
+ add("ld+mc+price", "Experienced bilingual MC: KES 15 k / event.")
69
+ add("ld+live-band+price","3-piece live band: KES 55 k / 2 sets.")
70
+
71
+ # -- décor --
72
+ add("ld+decor+price", "Décor starter pack (backdrop, 4 centrepieces, fairy-light): KES 35 k.")
73
+ add("ld+floral+price", "Fresh floral package: KES 20 k.")
74
+ add("ld+carpet+price", "Red-carpet 1 m x 10 m: KES 3 k.")
75
+
76
+ # -- photo / video --
77
+ add("ld+photography+price", "Event photography (8 hrs, edited album): KES 25 k.")
78
+ add("ld+videography+price", "4-camera 4K shoot + drone: KES 45 k.")
79
+ add("ld+drone+price", "Drone coverage 2 hrs: KES 10 k.")
80
+
81
+ # -- urgency / scarcity --
82
+ add("ld+weekend+urgency", "Only 2 silver packages left this weekend – pay now!")
83
+ add("ld+tomorrow+urgency","Tomorrow still possible – pay within 2 hrs.")
84
+
85
+ # -- payment confirmation --
86
+ add("ld+paid", "✅ Deposit received – gear locked! Run-sheet coming shortly.")
87
+ add("ld+receipt", "Forward MPESA message – we’ll confirm instantly.")
88
+
89
+ # ==========================================================
90
+ # 2. LAMAKI DESIGNS -- CONSTRUCTION CLOSING (new build, renovation, interior, fab)
91
+ # ==========================================================
92
+ construct_packages = {
93
+ "3bed-residential": ("4.8 M–6 M", "135 m² plinth, 4 months, NHBC warranty"),
94
+ "4bed-residential": ("6.5 M–8 M", "180 m² plinth, 5 months, NHBC warranty"),
95
+ "townhouse-3bed": ("5.2 M–6.8 M", "150 m², gated community specs"),
96
+ "commercial-showroom": ("12 k per m²", "shell + finishes, 6 months"),
97
+ "warehouse-shell": ("8 k per m²", "steel frame, 4 months"),
98
+ }
99
+ for pkg, (price, desc) in construct_packages.items():
100
+ add(f"lamaki+{pkg}+price", f"Lamaki {pkg.replace('-',' ')}: {price}. Incl: {desc}.")
101
+ add(f"lamaki+{pkg}+date", "When do you want ground broken? We slot you in.")
102
+ add(f"lamaki+{pkg}+pay+today", f"Pay 10 % mobilisation ({price}) today – we start drawings + approvals.")
103
+ add(f"lamaki+{pkg}+pay+later", f"Reserve 14 days – pay mobilisation {price} and we break ground.")
104
+
105
+ # -- renovation & remodelling --
106
+ reno_items = {
107
+ "kitchen-remodelling": "KES 450 k–650 k ( cabinets, granite tops, tiles, plumbing)",
108
+ "bathroom-upgrade": "KES 250 k–400 k ( fittings, shower, tiles, vanity)",
109
+ "master-ensuite-addition": "KES 650 k–850 k (new WC, shower, tiles, septic tie-in)",
110
+ "roofing-replacement": "KES 1.8 k per m² (stone-coated tiles, gutters, fascia)",
111
+ "exterior-painting": "KES 160 per m² (primer + 2 coats, scaffolding)",
112
+ "floor-tiling": "KES 1.2 k per m² (60×60 porcelain, labour + grout)",
113
+ }
114
+ for item, rate in reno_items.items():
115
+ clean = item.replace("-", " ").title()
116
+ add(f"lamaki+{item}+price", f"{clean}: {rate}.")
117
+ add(f"lamaki+{item}+date", "When should we start the works?")
118
+ add(f"lamaki+{item}+pay+today", f"Pay 30 % deposit ({rate}) today – materials ordered.")
119
+
120
+ # -- interior design --
121
+ add("lamaki+interior-design+price", "Full interior design package: KES 3 k per m² (3-D renders, mood board, shopping list).")
122
+ add("lamaki+furniture-package+price", "4-bedroom furniture package: KES 1.2 M (living, dining, beds, wardrobes).")
123
+ add("lamaki+cabinetry+price", "Custom cabinets + kitchen island: KES 380 k (melamine finish).")
124
+
125
+ # -- fabrication / joinery --
126
+ add("lamaki+wardrobe+price", "3-door sliding wardrobe: KES 85 k (includes install).")
127
+ add("lamaki+tv-stand+price", "Floating TV stand 2 m: KES 28 k.")
128
+ add("lamaki+office-desk+price", "Executive desk 1.6 m: KES 22 k.")
129
+
130
+ # -- architectural & engineering --
131
+ add("lamaki+architectural-drawings+price", "Approval-ready drawings: KES 120 k (concept + structural + county).")
132
+ add("lamaki+structural-engineer+price", "Engineer’s stamp: KES 40 k.")
133
+ add("lamaki+bill-of-quantities+price", "Detailed BoQ: KES 25 k.")
134
+
135
+ # -- warranties --
136
+ add("lamaki+warranty", "1-year defects liability + 5-year roof waterproofing + lifetime WhatsApp support.")
137
+
138
+ # -- process --
139
+ add("lamaki+process", "1. Free site visit 2. Concept + quote 3. Contract 4. Approvals 5. Construction 6. Hand-over.")
140
+
141
+ # -- FAQ --
142
+ add("lamaki+faq+financing", "We partner with Co-op & HF – our RM will call you.")
143
+ add("lamaki+faq+materials", "You can supply your own, but must meet KS standards.")
144
+ add("lamaki+faq+timeline", "Standard 3-bed = 4 months; renovation = 3–6 weeks.")
145
+
146
+ # -- urgency --
147
+ add("lamaki+slot-urgency", "Only 3 slots left this quarter – pay mobilisation today to secure.")
148
+ add("lamaki+material-price-rise", "Steel prices rise next month – lock today’s rate by paying now.")
149
+
150
+ # -- payment confirmation --
151
+ add("lamaki+paid", "✅ Mobilisation received – drawings start Monday, site hand-over schedule sent.")
152
+ add("lamaki+receipt", "Forward MPESA message – we’ll issue official receipt + contract.")
153
+
154
+ # ==========================================================
155
+ # 3. LOCATION / CHECKOUT (both divisions)
156
+ # ==========================================================
157
+ add("ld+location+checkout",
158
+ "LD Events yard: Utawala, Githunguri – weekdays 9-5, free sound-check demo.",
159
+ "Pop in Utawala, Githunguri – test speakers, lights, tents before you pay.",
160
+ )
161
+ add("lamaki+location+checkout",
162
+ "Lamaki Designs showroom: Utawala, Githunguri – weekdays 9-5, see finishes, sit in demo kitchen.",
163
+ "Visit us Utawala, Githunguri – 3-D walk-through of your house before we break ground.",
164
  )
165
 
166
+ # ==========================================================
167
+ # 4. DEFAULT GREETING (no company detected)
168
+ # ==========================================================
169
+ add("default+greeting",
170
+ "👋 Welcome! For construction & renovation text *LAMAKI*, for sound-light-tent packages text *LD*.",
171
+ "Hi! Type *LAMAKI* if you need building/renovation, or *LD* for events gear & packages.",
172
  )
173
 
174
+ # ==========================================================
175
+ # 5. SMALL-TALK (thanks, bye, ok)
176
+ # ==========================================================
177
+ add("thanks", "Karibu! Need anything else?", "You’re welcome – ready when you are.")
178
+ add("bye", "Kwaheri – we’ll make it amazing!", "See you soon – have a great day.")
179
+ add("ok", "Cool – next step?", "Alright what else can we sort?")
180
+
181
+ # ==========================================================
182
+ # 6. CANONICALISER
183
+ # ==========================================================
184
+ def detect_company_and_intent(text: str) -> tuple[str, str]:
185
+ t = re.sub(r"[^a-z0-9+]", " ", text.lower()).strip()
186
+ # company
187
+ if any(k in t for k in ("lamaki", "construction", "renovation", "build", "house", "bungalow", "townhouse", "kitchen", "bathroom", "roofing", "interior", "cabinet", "wardrobe")):
188
+ company = "lamaki"
189
+ elif any(k in t for k in ("ld", "event", "sound", "light", "tent", "stage", "speaker", "mic", "wedding", "concert", "dj", "led", "screen", "decor", "floral", "carpet")):
190
+ company = "ld"
191
+ else:
192
+ company = "default" # will send greeting
193
+
194
+ # intent
195
+ if any(k in t for k in ("location", "yard", "showroom", "visit", "checkout", "inspect", "utawala", "githunguri")):
196
+ return company, "location+checkout"
197
+ if any(k in t for k in ("price", "cost", "how much", "quote")):
198
+ return company, "price"
199
+ if any(k in t for k in ("date", "when", "which day", "what day")):
200
+ return company, "date"
201
+ if any(k in t for k in ("pay", "mpesa", "send", "lock", "book", "reserve", "today", "now")):
202
+ if "today" in t or "now" in t:
203
+ return company, "pay+today"
204
+ if "deposit" in t:
205
+ return company, "deposit"
206
+ if "later" in t or "tomorrow" in t:
207
+ return company, "pay+later"
208
+ return company, "pay+today"
209
+ if any(k in t for k in ("paid", "sent", "done")):
210
+ return company, "paid"
211
+ if "receipt" in t or "forward" in t:
212
+ return company, "receipt"
213
+ if any(k in t for k in ("thanks", "asante")):
214
+ return company, "thanks"
215
+ if any(k in t for k in ("bye", "goodbye", "see you")):
216
+ return company, "bye"
217
+ if "ok" in t or "cool" in t:
218
+ return company, "ok"
219
+ if any(k in t for k in ("hello", "hi", "hey", "jambo")):
220
+ return company, "greeting"
221
+ return company, "price" # drive to price by default
222
+
223
+
224
+ def secretary_reply(text: str) -> str:
225
+ company, intent = detect_company_and_intent(text)
226
+ key = f"{company}+{intent}"
227
+ if key in REPLIES:
228
+ return rand(key)
229
+ # fallback chain
230
+ for fall in (f"{company}+price", f"{company}+date", "default+greeting"):
231
+ if fall in REPLIES:
232
+ return rand(fall)
233
+ return "Please text *LD* for events or *LAMAKI* for construction – we’ll sort you instantly."
234
+
235
+
236
+ # ==========================================================
237
+ # 7. WEBHOOK
238
+ # ==========================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  app = Flask(__name__)
240
 
241
 
242
  @app.post("/whatsapp")
243
  def whatsapp():
 
244
  if request.json.get("verify") != VERIFY_TOKEN:
245
  return jsonify(error="bad token"), 403
246
  user = request.json.get("from", "unknown")
247
  msg = request.json.get("text", "").strip()
248
+ if not msg:
249
+ return jsonify(reply="Kindly send a text message.")
250
  save_msg(user, msg, "user")
251
+ ans = secretary_reply(msg)
252
  save_msg(user, ans, "assistant")
253
  return jsonify(reply=ans)
254
 
 
259
 
260
 
261
  if __name__ == "__main__":
 
262
  app.run(host="0.0.0.0", port=7860, threaded=True)