muddasser commited on
Commit
23dc352
·
verified ·
1 Parent(s): ea83b5b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +847 -52
app.py CHANGED
@@ -1,69 +1,864 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
- from huggingface_hub import InferenceClient
3
-
4
-
5
- def respond(
6
- message,
7
- history: list[dict[str, str]],
8
- system_message,
9
- max_tokens,
10
- temperature,
11
- top_p,
12
- hf_token: gr.OAuthToken,
13
- ):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  """
15
- For more information on `huggingface_hub` Inference API support, please check the docs: https://huggingface.co/docs/huggingface_hub/v0.22.2/en/guides/inference
 
16
  """
17
- client = InferenceClient(token=hf_token.token, model="openai/gpt-oss-20b")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
- messages = [{"role": "system", "content": system_message}]
 
 
20
 
21
- messages.extend(history)
 
 
 
 
 
 
22
 
23
- messages.append({"role": "user", "content": message})
24
 
25
- response = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- for message in client.chat_completion(
28
- messages,
29
- max_tokens=max_tokens,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  stream=True,
31
- temperature=temperature,
32
- top_p=top_p,
33
- ):
34
- choices = message.choices
35
- token = ""
36
- if len(choices) and choices[0].delta.content:
37
- token = choices[0].delta.content
 
 
 
 
 
 
 
 
 
38
 
39
- response += token
40
- yield response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  """
44
- For information on how to customize the ChatInterface, peruse the gradio docs: https://www.gradio.app/docs/chatinterface
 
 
 
 
 
 
 
 
 
 
45
  """
46
- chatbot = gr.ChatInterface(
47
- respond,
48
- additional_inputs=[
49
- gr.Textbox(value="You are a friendly Chatbot.", label="System message"),
50
- gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="Max new tokens"),
51
- gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
52
- gr.Slider(
53
- minimum=0.1,
54
- maximum=1.0,
55
- value=0.95,
56
- step=0.05,
57
- label="Top-p (nucleus sampling)",
58
- ),
59
- ],
60
- )
61
-
62
- with gr.Blocks() as demo:
63
- with gr.Sidebar():
64
- gr.LoginButton()
65
- chatbot.render()
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
  if __name__ == "__main__":
69
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 📞 Reem — Real-Time Streaming Voice RAG Bot
3
+ Gradio on Hugging Face Spaces
4
+ STT: Deepgram WebSocket | LLM: Groq | TTS: edge-tts
5
+ """
6
+
7
+ import os, base64, asyncio, sqlite3, re, json, tempfile, threading, time
8
+ from pathlib import Path
9
+ from collections import deque
10
+ from dataclasses import dataclass, field
11
+ from typing import Optional, Generator
12
+ import numpy as np
13
+ import faiss
14
  import gradio as gr
15
+ import edge_tts
16
+ from groq import Groq
17
+ from sentence_transformers import SentenceTransformer
18
+ from deepgram import DeepgramClient, LiveTranscriptionEvents, LiveOptions
19
+ from pydub import AudioSegment
20
+ import httpx
21
+
22
+ # ── Env / Constants ────────────────────────────────────────────────────────
23
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
24
+ DEEPGRAM_API_KEY = os.environ.get("DEEPGRAM_API_KEY", "")
25
+
26
+ EMBED_MODEL = "all-MiniLM-L6-v2"
27
+ TOP_K = 5
28
+ MEMORY_TURNS = 10
29
+ FAISS_INDEX_PATH = "faiss.index"
30
+ CHUNKS_PATH = "chunks.npy"
31
+ MIN_AUDIO_BYTES = 8000
32
+ DB_PATH = "saudi_orders_database.db"
33
+
34
+ DB_SCHEMA = """
35
+ Table: orders
36
+ Columns:
37
+ order_id INTEGER (primary key)
38
+ order_date TEXT
39
+ customer_name TEXT
40
+ customer_city TEXT
41
+ customer_address TEXT
42
+ status TEXT (e.g. 'Delivered', 'In Transit', 'Pending')
43
+ delivery_date TEXT
44
+ comments TEXT
45
+ """
46
+
47
+ VOICES = {
48
+ "en": "en-US-AriaNeural",
49
+ "ar": "ar-SA-ZariyahNeural",
50
+ "ur": "ur-PK-UzmaNeural",
51
+ }
52
+
53
+ WELCOME_MESSAGES = {
54
+ "en": "Welcome to our virtual call centre. For English say English.",
55
+ "ar": "مرحباً بكم في مركز الاتصال الافتراضي. للعربية قل العربية.",
56
+ "ur": "ہمارے ورچوئل کال سینٹر میں خوش آمدید۔ اردو کے لیے اردو کہیں۔",
57
+ }
58
+
59
+ LANGUAGE_CONFIRMATION = {
60
+ "en": "Thank you for choosing English. How can I help you today?",
61
+ "ar": "شكراً لاختياركم العربية. كيف يمكنني مساعدتكم اليوم؟",
62
+ "ur": "اردو منتخب کرنے کا شکریہ۔ میں آپ کی کیسے مدد کر سکتی ہوں؟",
63
+ }
64
+
65
+ LANG_KEYWORDS = {
66
+ "en": ["english", "انگلش"],
67
+ "ar": ["arabic", "عربي", "عربية", "العربية"],
68
+ "ur": ["urdu", "اردو"],
69
+ }
70
+
71
+ ORDER_KEYWORDS = [
72
+ "order","track","status","delivery","deliver","shipment","shipped",
73
+ "parcel","package","where is my","when will",
74
+ "طلب","تتبع","حالة","توصيل","شحن",
75
+ "آرڈر","ٹریک","ڈیلیوری","اسٹیٹس",
76
+ ]
77
+
78
+
79
+ # ══════════════════════════════════════════════════════════════════════════
80
+ # MEMORY
81
+ # ══════════════════════════════════════════════════════════════════════════
82
+
83
+ @dataclass
84
+ class Turn:
85
+ role: str
86
+ content: str
87
+ entities: dict = field(default_factory=dict)
88
+ route: str = ""
89
+
90
+
91
+ class ConversationMemory:
92
+ def __init__(self, max_turns: int = MEMORY_TURNS):
93
+ self.turns: deque[Turn] = deque(maxlen=max_turns)
94
+ self.entity_cache: dict = {
95
+ "order_ids": [], "customer_name": None,
96
+ "last_topic": None, "city": None,
97
+ }
98
+
99
+ def extract_entities(self, text: str) -> dict:
100
+ entities = {}
101
+ ids = re.findall(r"\b(\d{3,})\b", text)
102
+ if ids:
103
+ entities["order_ids"] = [int(i) for i in ids]
104
+ name_match = re.search(
105
+ r"(?:my name is|i am|customer|اسمي|نام)\s+([A-Za-z\u0600-\u06FF]{2,})",
106
+ text, re.IGNORECASE)
107
+ if name_match:
108
+ entities["customer_name"] = name_match.group(1)
109
+ return entities
110
+
111
+ def update_cache(self, entities: dict):
112
+ if "order_ids" in entities:
113
+ for oid in entities["order_ids"]:
114
+ if oid not in self.entity_cache["order_ids"]:
115
+ self.entity_cache["order_ids"].append(oid)
116
+ if "customer_name" in entities:
117
+ self.entity_cache["customer_name"] = entities["customer_name"]
118
+
119
+ def add_turn(self, role: str, content: str, route: str = ""):
120
+ entities = self.extract_entities(content) if role == "user" else {}
121
+ self.update_cache(entities)
122
+ self.turns.append(Turn(role=role, content=content,
123
+ entities=entities, route=route))
124
+
125
+ def history_text(self, max_chars: int = 1200) -> str:
126
+ lines = []
127
+ for t in self.turns:
128
+ prefix = "Customer" if t.role == "user" else "Reem"
129
+ lines.append(f"{prefix}: {t.content}")
130
+ text = "\n".join(lines)
131
+ return text[-max_chars:] if len(text) > max_chars else text
132
+
133
+ def resolve_order_id(self, query: str) -> Optional[int]:
134
+ ids_in_query = re.findall(r"\b(\d{3,})\b", query)
135
+ if ids_in_query:
136
+ return int(ids_in_query[-1])
137
+ implicit = ["it","that order","same order","my order","the order",
138
+ "طلبي","آرڈر","وہ"]
139
+ if any(t in query.lower() for t in implicit):
140
+ if self.entity_cache["order_ids"]:
141
+ return self.entity_cache["order_ids"][-1]
142
+ return None
143
+
144
+ def memory_keywords(self) -> list[str]:
145
+ keywords = []
146
+ for t in list(self.turns)[-3:]:
147
+ if t.role == "user":
148
+ keywords += re.findall(r"\b\w{4,}\b", t.content.lower())
149
+ return list(set(keywords))
150
+
151
+
152
+ # ══════════════════════════════════════════════════════════════════════════
153
+ # SINGLETONS
154
+ # ══════════════════════════════════════════════════════════════════════════
155
+
156
+ _groq_client: Optional[Groq] = None
157
+ _embedder = None
158
+ _faiss_index = None
159
+ _chunks = None
160
+
161
+ def get_groq() -> Groq:
162
+ global _groq_client
163
+ if _groq_client is None:
164
+ _groq_client = Groq(api_key=GROQ_API_KEY)
165
+ return _groq_client
166
+
167
+ def get_embedder():
168
+ global _embedder
169
+ if _embedder is None:
170
+ _embedder = SentenceTransformer(EMBED_MODEL)
171
+ return _embedder
172
+
173
+ def get_index():
174
+ global _faiss_index, _chunks
175
+ if _faiss_index is None:
176
+ if Path(FAISS_INDEX_PATH).exists() and Path(CHUNKS_PATH).exists():
177
+ _faiss_index = faiss.read_index(FAISS_INDEX_PATH)
178
+ _chunks = np.load(CHUNKS_PATH, allow_pickle=True)
179
+ return _faiss_index, _chunks
180
+
181
+
182
+ # ══════════════════════════════════════════════════════════════════════════
183
+ # DEEPGRAM STT (streaming WebSocket)
184
+ # ══════════════════════════════════════════════════════════════════════════
185
+
186
+ def transcribe_audio_bytes(audio_bytes: bytes) -> str:
187
+ """
188
+ Send raw audio bytes to Deepgram's REST pre-recorded API.
189
+ Used for push-to-talk mic chunks from Gradio.
190
+ """
191
+ if len(audio_bytes) < MIN_AUDIO_BYTES:
192
+ return ""
193
+ try:
194
+ headers = {
195
+ "Authorization": f"Token {DEEPGRAM_API_KEY}",
196
+ "Content-Type": "audio/wav",
197
+ }
198
+ params = {
199
+ "model": "nova-2",
200
+ "language": "multi", # auto-detect Arabic / English / Urdu
201
+ "smart_format": "true",
202
+ "punctuate": "true",
203
+ }
204
+ resp = httpx.post(
205
+ "https://api.deepgram.com/v1/listen",
206
+ headers=headers,
207
+ params=params,
208
+ content=audio_bytes,
209
+ timeout=20,
210
+ )
211
+ resp.raise_for_status()
212
+ data = resp.json()
213
+ return (data["results"]["channels"][0]
214
+ ["alternatives"][0]["transcript"].strip())
215
+ except Exception as e:
216
+ print(f"[Deepgram STT error] {e}")
217
+ return ""
218
+
219
+
220
+ class DeepgramStreamingSTT:
221
  """
222
+ WebSocket-based streaming STT for real-time barge-in detection.
223
+ Runs in a background thread; emits final transcripts via callback.
224
  """
225
+ def __init__(self, on_transcript):
226
+ self.on_transcript = on_transcript
227
+ self._conn = None
228
+ self._loop = None
229
+ self._thread = None
230
+ self.running = False
231
+
232
+ def start(self):
233
+ self.running = True
234
+ self._thread = threading.Thread(target=self._run_loop, daemon=True)
235
+ self._thread.start()
236
+
237
+ def _run_loop(self):
238
+ self._loop = asyncio.new_event_loop()
239
+ asyncio.set_event_loop(self._loop)
240
+ self._loop.run_until_complete(self._connect())
241
+
242
+ async def _connect(self):
243
+ dg = DeepgramClient(DEEPGRAM_API_KEY)
244
+ self._conn = dg.listen.asyncwebsocket.v("1")
245
+
246
+ self._conn.on(LiveTranscriptionEvents.Transcript, self._on_msg)
247
+
248
+ opts = LiveOptions(
249
+ model="nova-2",
250
+ language="multi",
251
+ smart_format=True,
252
+ interim_results=True,
253
+ endpointing=400,
254
+ )
255
+ await self._conn.start(opts)
256
+
257
+ while self.running:
258
+ await asyncio.sleep(0.05)
259
+
260
+ await self._conn.finish()
261
+
262
+ async def _on_msg(self, _self, result, **kwargs):
263
+ try:
264
+ alt = result.channel.alternatives[0]
265
+ if result.is_final and alt.transcript.strip():
266
+ self.on_transcript(alt.transcript.strip())
267
+ except Exception:
268
+ pass
269
+
270
+ def send_audio(self, chunk: bytes):
271
+ if self._conn and self._loop and self.running:
272
+ asyncio.run_coroutine_threadsafe(
273
+ self._conn.send(chunk), self._loop)
274
+
275
+ def stop(self):
276
+ self.running = False
277
+
278
+
279
+ # ══════════════════════════════════════════════════════════════════════════
280
+ # TTS
281
+ # ══════════════════════════════════════════════════════════════════════════
282
+
283
+ async def _tts_async(text: str, voice: str, out_path: str):
284
+ communicate = edge_tts.Communicate(text, voice)
285
+ await communicate.save(out_path)
286
+
287
+
288
+ def tts_to_file(text: str, lang_code: str) -> str:
289
+ """Returns path to generated mp3 file."""
290
+ voice = VOICES.get(lang_code, VOICES["en"])
291
+ tmp = tempfile.mktemp(suffix=".mp3")
292
+ asyncio.run(_tts_async(text, voice, tmp))
293
+ return tmp
294
+
295
+
296
+ def tts_bytes(text: str, lang_code: str) -> bytes:
297
+ path = tts_to_file(text, lang_code)
298
+ try:
299
+ return Path(path).read_bytes()
300
+ finally:
301
+ Path(path).unlink(missing_ok=True)
302
+
303
+
304
+ def welcome_audio_b64() -> str:
305
+ combined = AudioSegment.empty()
306
+ for lang_code, text in WELCOME_MESSAGES.items():
307
+ data = tts_bytes(text, lang_code)
308
+ tmp = tempfile.mktemp(suffix=".mp3")
309
+ Path(tmp).write_bytes(data)
310
+ combined += AudioSegment.from_mp3(tmp) + AudioSegment.silent(700)
311
+ Path(tmp).unlink(missing_ok=True)
312
+ out = tempfile.mktemp(suffix=".mp3")
313
+ combined.export(out, format="mp3")
314
+ b64 = base64.b64encode(Path(out).read_bytes()).decode()
315
+ Path(out).unlink(missing_ok=True)
316
+ return b64
317
+
318
+
319
+ # ══════════════════════════════════════════════════════════════════════════
320
+ # ROUTING
321
+ # ══════════════════════════════════════════════════════════════════════════
322
+
323
+ def is_order_query(text: str) -> bool:
324
+ t = text.lower()
325
+ return any(kw in t for kw in ORDER_KEYWORDS)
326
+
327
+
328
+ def route_query(query: str, memory: ConversationMemory) -> str:
329
+ if is_order_query(query) or re.search(r"\b\d{3,}\b", query):
330
+ return "sql"
331
+ recent_routes = [t.route for t in list(memory.turns)[-3:] if t.role == "user"]
332
+ if recent_routes and recent_routes[-1] == "sql":
333
+ if any(sig in query.lower() for sig in
334
+ ["when","why","how long","address","city","متى","کب"]):
335
+ return "sql"
336
+ prompt = (
337
+ "You are an intent router. Return ONLY 'sql' or 'rag'.\n"
338
+ f"Context:\n{memory.history_text(400)}\nQuery: {query}"
339
+ )
340
+ r = get_groq().chat.completions.create(
341
+ model="llama-3.3-70b-versatile", temperature=0, max_tokens=5,
342
+ messages=[{"role": "user", "content": prompt}]
343
+ )
344
+ route = r.choices[0].message.content.strip().lower()
345
+ return route if route in ("sql", "rag") else "rag"
346
+
347
 
348
+ # ══════════════════════════════════════════════════════════════════════════
349
+ # RAG
350
+ # ══════════════════════════════════════════════════════════════════════════
351
 
352
+ def chunk_text(text, size=300, overlap=50):
353
+ words = text.split()
354
+ chunks, i = [], 0
355
+ while i < len(words):
356
+ chunks.append(" ".join(words[i:i+size]))
357
+ i += size - overlap
358
+ return chunks
359
 
 
360
 
361
+ def build_index(texts: list[str]):
362
+ global _faiss_index, _chunks
363
+ embedder = get_embedder()
364
+ all_chunks = []
365
+ for t in texts:
366
+ all_chunks.extend(chunk_text(t))
367
+ if not all_chunks:
368
+ raise ValueError("No text found.")
369
+ vecs = embedder.encode(all_chunks, show_progress_bar=False,
370
+ convert_to_numpy=True).astype(np.float32)
371
+ faiss.normalize_L2(vecs)
372
+ idx = faiss.IndexFlatIP(vecs.shape[1])
373
+ idx.add(vecs)
374
+ faiss.write_index(idx, FAISS_INDEX_PATH)
375
+ np.save(CHUNKS_PATH, np.array(all_chunks, dtype=object))
376
+ _faiss_index = idx
377
+ _chunks = np.array(all_chunks, dtype=object)
378
+ return idx, _chunks
379
 
380
+
381
+ def retrieve(query: str, index, chunks,
382
+ memory: ConversationMemory) -> list[str]:
383
+ embedder = get_embedder()
384
+ history_snippet = memory.history_text(300)
385
+ enriched = f"{history_snippet}\n\nQuestion: {query}" if history_snippet else query
386
+ q = embedder.encode([enriched], convert_to_numpy=True).astype(np.float32)
387
+ faiss.normalize_L2(q)
388
+ _, ids = index.search(q, TOP_K)
389
+ candidates = [chunks[i] for i in ids[0] if i >= 0]
390
+ keywords = memory.memory_keywords()
391
+ if keywords:
392
+ candidates.sort(key=lambda c: sum(1 for kw in keywords if kw in c.lower()),
393
+ reverse=True)
394
+ return candidates[:3]
395
+
396
+
397
+ # ══════════════════════════════════════════════════════════════════════════
398
+ # SQL PATH
399
+ # ══════════════════════════════════════════════════════════════════════════
400
+
401
+ def generate_sql(query: str, memory: ConversationMemory) -> str:
402
+ entity_hint = ""
403
+ if memory.entity_cache["customer_name"]:
404
+ entity_hint += f"\nKnown customer: {memory.entity_cache['customer_name']}"
405
+ if memory.entity_cache["order_ids"]:
406
+ entity_hint += f"\nPrior order IDs: {memory.entity_cache['order_ids']}"
407
+ prompt = (
408
+ f"You are a SQL expert.\nSchema:\n{DB_SCHEMA}{entity_hint}\n"
409
+ f"History:\n{memory.history_text(400)}\n"
410
+ f"Write a safe SELECT query for: {query}\n"
411
+ "Return ONLY raw SQL, no markdown."
412
+ )
413
+ r = get_groq().chat.completions.create(
414
+ model="llama-3.3-70b-versatile", temperature=0, max_tokens=200,
415
+ messages=[{"role": "user", "content": prompt}]
416
+ )
417
+ sql = r.choices[0].message.content.strip()
418
+ return re.sub(r"```sql|```", "", sql).strip()
419
+
420
+
421
+ def run_sql(sql: str):
422
+ if not Path(DB_PATH).exists():
423
+ return None, "Database not found."
424
+ if not sql.strip().upper().startswith("SELECT"):
425
+ return None, "Only SELECT allowed."
426
+ try:
427
+ conn = sqlite3.connect(DB_PATH)
428
+ cur = conn.cursor()
429
+ cur.execute(sql)
430
+ rows = cur.fetchall()
431
+ cols = [d[0] for d in cur.description]
432
+ conn.close()
433
+ return (cols, rows), None
434
+ except Exception as e:
435
+ return None, str(e)
436
+
437
+
438
+ def answer_from_db(query, cols, rows, lang_code, memory) -> str:
439
+ lang_instr = {"en": "English only.", "ar": "Arabic only.", "ur": "Urdu only."}.get(lang_code, "English only.")
440
+ if not rows:
441
+ return {"en": "I couldn't find any matching order. Please check your order ID.",
442
+ "ar": "لم أجد أي طلب مطابق. يرجى التحقق من رقم الطلب.",
443
+ "ur": "کوئی ملتا جلتا آرڈر نہیں ملا۔ آرڈر نمبر چیک کریں۔"}.get(lang_code, "")
444
+ prompt = (
445
+ f"You are Reem, a professional call centre agent. {lang_instr}\n"
446
+ f"History:\n{memory.history_text(500)}\n"
447
+ f"Customer asked: \"{query}\"\n"
448
+ f"DB result — Columns: {cols}, Rows: {rows}\n"
449
+ "Give a friendly 2-3 sentence answer. No SQL mention."
450
+ )
451
+ r = get_groq().chat.completions.create(
452
+ model="llama-3.3-70b-versatile", temperature=0.1, max_tokens=250,
453
+ messages=[{"role": "user", "content": prompt}]
454
+ )
455
+ return r.choices[0].message.content.strip()
456
+
457
+
458
+ # ══════════════════════════════════════════════════════════════════════════
459
+ # LLM (RAG) — STREAMING
460
+ # ══════════════════════════════════════════════════════════════════════════
461
+
462
+ def ask_llm_stream(query: str, ctx_chunks: list[str],
463
+ lang_code: str, memory: ConversationMemory) -> Generator[str, None, None]:
464
+ """Yields token-by-token streamed response from Groq."""
465
+ context = "\n\n".join(ctx_chunks[:3])[:2800]
466
+ lang_instr = {"en": "Respond in English only.",
467
+ "ar": "أجب باللغة العربية فقط.",
468
+ "ur": "صرف اردو میں جواب دیں۔"}.get(lang_code, "Respond in English only.")
469
+ system = (
470
+ f"You are Reem, a professional female call centre agent. {lang_instr} "
471
+ "Use conversation history for context-aware answers. "
472
+ "If a follow-up, refer naturally to prior answers. Max 3 sentences."
473
+ )
474
+ user_msg = (
475
+ f"History:\n{memory.history_text(600)}\n\n"
476
+ f"Knowledge base:\n{context}\n\n"
477
+ f"Customer question: {query}"
478
+ )
479
+ stream = get_groq().chat.completions.create(
480
+ model="llama-3.3-70b-versatile",
481
+ temperature=0.1,
482
+ max_tokens=250,
483
  stream=True,
484
+ messages=[{"role": "system", "content": system},
485
+ {"role": "user", "content": user_msg}]
486
+ )
487
+ for chunk in stream:
488
+ delta = chunk.choices[0].delta.content
489
+ if delta:
490
+ yield delta
491
+
492
+
493
+ def ask_llm(query, ctx_chunks, lang_code, memory) -> str:
494
+ return "".join(ask_llm_stream(query, ctx_chunks, lang_code, memory))
495
+
496
+
497
+ # ══════════════════════════════════════════════════════════════════════════
498
+ # CORE PROCESSING
499
+ # ══════════════════════════════════════════════════════════════════════════
500
 
501
+ def process_query(query: str, memory: ConversationMemory,
502
+ lang_code: str, chat_history: list) -> tuple[str, list, str]:
503
+ """
504
+ Returns (answer_text, updated_chat_history, tts_audio_path).
505
+ """
506
+ if not query or not query.strip():
507
+ return "", chat_history, None
508
+
509
+ route = route_query(query, memory)
510
+
511
+ # ── SQL ──────────────────────────────────────────────────────────
512
+ if route == "sql":
513
+ order_id = memory.resolve_order_id(query)
514
+ if not order_id and not re.search(r"\b\d{3,}\b", query):
515
+ ans = {"en": "I can help track your order. Please provide your order ID.",
516
+ "ar": "يمكنني مساعدتك بتتبع طلبك. يرجى تزويدي برقم الطلب.",
517
+ "ur": "براہ کرم آرڈر نمبر دیں۔"}.get(lang_code)
518
+ else:
519
+ sql = generate_sql(query, memory)
520
+ result, error = run_sql(sql)
521
+ if error:
522
+ ans = {"en": "Sorry, I couldn't retrieve that order.",
523
+ "ar": "عذراً، لم أتمكن من استرداد الطلب.",
524
+ "ur": "معذرت، آرڈر نہیں ملا۔"}.get(lang_code)
525
+ else:
526
+ cols, rows = result
527
+ ans = answer_from_db(query, cols, rows, lang_code, memory)
528
+
529
+ # ── RAG ──────────────────────────────────────────────────────────
530
+ else:
531
+ index, chunks = get_index()
532
+ if index is None:
533
+ ans = "No knowledge base loaded yet. Please upload TXT files first."
534
+ else:
535
+ ctx = retrieve(query, index, chunks, memory)
536
+ ans = ask_llm(query, ctx, lang_code, memory)
537
+
538
+ memory.add_turn("user", query, route=route)
539
+ memory.add_turn("assistant", ans, route=route)
540
+
541
+ chat_history = chat_history + [
542
+ {"role": "user", "content": query},
543
+ {"role": "assistant", "content": ans},
544
+ ]
545
+
546
+ # TTS
547
+ audio_path = tts_to_file(ans, lang_code)
548
+ return ans, chat_history, audio_path
549
 
550
 
551
+ def detect_lang(text: str) -> Optional[str]:
552
+ t = text.lower().strip()
553
+ for code, keywords in LANG_KEYWORDS.items():
554
+ if any(kw in t for kw in keywords):
555
+ return code
556
+ return None
557
+
558
+
559
+ # ══════════════════════════════════════════════════════════════════════════
560
+ # GRADIO UI
561
+ # ══════════════════════════════════════════════════════════════════════════
562
+
563
+ CSS = """
564
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
565
+ body, .gradio-container { background: linear-gradient(145deg,#0a0f1a 0%,#131b2e 60%,#0d1526 100%) !important; font-family: 'Inter', sans-serif; }
566
+ .hero { text-align:center; padding:1.5rem 2rem 1.25rem; margin-bottom:1rem;
567
+ background:linear-gradient(135deg,rgba(16,185,129,0.1),rgba(245,158,11,0.06));
568
+ border:1px solid rgba(255,255,255,0.08); border-radius:20px; }
569
+ .hero h1 { font-size:1.8rem; font-weight:700; color:#f1f5f9; margin:0; }
570
+ .hero p { color:#94a3b8; margin:0.35rem 0 0; font-size:0.95rem; }
571
+ .badge { display:inline-block; margin-top:0.6rem; padding:0.3rem 0.85rem;
572
+ background:rgba(16,185,129,0.15); border:1px solid rgba(16,185,129,0.35);
573
+ border-radius:999px; color:#34d399; font-size:0.78rem; font-weight:500; }
574
+ .panel { background:rgba(17,24,39,0.75); border:1px solid rgba(255,255,255,0.08);
575
+ border-radius:16px; padding:1.2rem; margin-bottom:0.75rem; }
576
+ #chatbot { background:rgba(17,24,39,0.7) !important; border-radius:14px !important; }
577
+ .message.user { background:rgba(16,185,129,0.12) !important; border:1px solid rgba(16,185,129,0.25) !important; color:#f1f5f9 !important; border-radius:12px !important; }
578
+ .message.bot { background:rgba(245,158,11,0.1) !important; border:1px solid rgba(245,158,11,0.2) !important; color:#f1f5f9 !important; border-radius:12px !important; }
579
+ .gr-button-primary { background:linear-gradient(135deg,#10b981,#059669) !important; border:none !important; font-weight:600 !important; }
580
+ footer { display:none !important; }
581
  """
582
+
583
+ AVATAR_HTML = """
584
+ <div style="background:linear-gradient(160deg,#0f172a,#1e293b);border-radius:20px;padding:24px;text-align:center;border:1px solid rgba(255,255,255,0.08);">
585
+ <div style="position:relative;width:160px;height:160px;margin:0 auto 16px;border-radius:50%;background:linear-gradient(135deg,#1e3a5f,#0f766e);display:flex;align-items:center;justify-content:center;font-size:72px;box-shadow:0 8px 32px rgba(16,185,129,0.2);">
586
+ 👩‍💼
587
+ <div style="position:absolute;top:8px;right:8px;width:14px;height:14px;background:#10b981;border:2px solid #0f172a;border-radius:50%;box-shadow:0 0 8px rgba(16,185,129,0.8);"></div>
588
+ </div>
589
+ <p style="font-size:1.1rem;font-weight:600;color:#f8fafc;margin:0 0 4px;">Reem</p>
590
+ <p style="font-size:0.8rem;color:#94a3b8;margin:0 0 12px;">Virtual Call Centre Agent</p>
591
+ <span style="background:rgba(16,185,129,0.12);border:1px solid rgba(16,185,129,0.35);border-radius:999px;color:#34d399;padding:5px 14px;font-size:0.78rem;">🟢 Online</span>
592
+ </div>
593
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
 
595
+ def build_app():
596
+ # Pre-load welcome audio once
597
+ print("⏳ Generating welcome audio…")
598
+ welcome_b64 = welcome_audio_b64()
599
+ print("✅ Welcome audio ready.")
600
+
601
+ with gr.Blocks(css=CSS, title="Reem — Virtual Call Centre") as demo:
602
+
603
+ # ── State ──────────────────────────────────────────────────────
604
+ memory_state = gr.State(ConversationMemory())
605
+ lang_state = gr.State("") # "" = not selected yet
606
+ chat_history = gr.State([])
607
+
608
+ # ── Hero ───────────────────────────────────────────────────────
609
+ gr.HTML("""
610
+ <div class="hero">
611
+ <h1>📞 Virtual Call Centre</h1>
612
+ <p>XYZ Holdings — powered by AI agent <strong>Reem</strong></p>
613
+ <span class="badge">🟢 Online · Voice &amp; Text · English / العربية / اردو</span>
614
+ </div>
615
+ """)
616
+
617
+ with gr.Row():
618
+ # ── Left: Avatar + Admin ───────────────────────────────────
619
+ with gr.Column(scale=1, min_width=260):
620
+ gr.HTML(AVATAR_HTML)
621
+
622
+ with gr.Accordion("⚙️ Admin Panel", open=False):
623
+ gr.Markdown("**📚 Knowledge Base**")
624
+ kb_files = gr.File(label="Upload TXT files",
625
+ file_types=[".txt"],
626
+ file_count="multiple")
627
+ build_btn = gr.Button("Build KB", variant="primary", size="sm")
628
+ kb_status = gr.Textbox(label="KB Status", interactive=False,
629
+ lines=1, value="No KB loaded")
630
+
631
+ gr.Markdown("**🗄️ Orders Database**")
632
+ db_file = gr.File(label="Upload .db file",
633
+ file_types=[".db"])
634
+ db_status = gr.Textbox(label="DB Status", interactive=False,
635
+ lines=1,
636
+ value="✅ Connected" if Path(DB_PATH).exists() else "❌ Not found")
637
+
638
+ gr.Markdown("**🧠 Memory**")
639
+ mem_info = gr.Textbox(label="Entity cache", interactive=False, lines=2)
640
+ clear_btn = gr.Button("🗑️ Clear memory", variant="secondary", size="sm")
641
+
642
+ # ── Right: Chat ────────────────────────────────────────────
643
+ with gr.Column(scale=2):
644
+
645
+ # ── Language selection (shown until lang chosen) ───────
646
+ with gr.Group(visible=True) as lang_group:
647
+ gr.Markdown("### 🌐 Choose your language")
648
+ gr.Markdown("Speak below or tap a button.")
649
+ lang_mic = gr.Audio(
650
+ sources=["microphone"],
651
+ type="numpy",
652
+ label="🎙️ Say your language",
653
+ streaming=False,
654
+ )
655
+ with gr.Row():
656
+ btn_en = gr.Button("🇬🇧 English", variant="primary")
657
+ btn_ar = gr.Button("🇸🇦 العربية", variant="primary")
658
+ btn_ur = gr.Button("🇵🇰 اردو", variant="primary")
659
+ lang_audio_out = gr.Audio(
660
+ label="Agent", autoplay=True,
661
+ visible=False, interactive=False)
662
+
663
+ # ── Main chat interface (hidden until lang chosen) ─────
664
+ with gr.Group(visible=False) as chat_group:
665
+ lang_badge = gr.Markdown("🗣️ Language: **English**")
666
+ chatbot = gr.Chatbot(
667
+ elem_id="chatbot",
668
+ label="Reem",
669
+ height=400,
670
+ type="messages",
671
+ )
672
+ with gr.Row():
673
+ voice_input = gr.Audio(
674
+ sources=["microphone"],
675
+ type="numpy",
676
+ label="🎙️ Speak",
677
+ streaming=False,
678
+ )
679
+ txt_input = gr.Textbox(
680
+ placeholder="Type your message or order number…",
681
+ label="",
682
+ lines=1,
683
+ )
684
+ with gr.Row():
685
+ send_btn = gr.Button("Send ➤", variant="primary")
686
+ change_btn = gr.Button("🔄 Change language", variant="secondary")
687
+ audio_out = gr.Audio(
688
+ label="Reem speaks",
689
+ autoplay=True,
690
+ interactive=False,
691
+ )
692
+
693
+ # Welcome audio hidden autoplay on load
694
+ welcome_audio_html = gr.HTML(
695
+ f'<audio autoplay style="display:none" src="data:audio/mp3;base64,{welcome_b64}"></audio>'
696
+ )
697
+
698
+ # ══════════════════════════════════════════════════════════════
699
+ # HANDLERS
700
+ # ══════════════════════════════════════════════════════════════
701
+
702
+ # ── Build KB ──────────────────────────────────────────────────
703
+ def handle_build_kb(files):
704
+ if not files:
705
+ return "❌ No files uploaded."
706
+ texts = []
707
+ for f in files:
708
+ texts.append(Path(f.name).read_text(encoding="utf-8", errors="ignore"))
709
+ try:
710
+ _, chunks = build_index(texts)
711
+ return f"✅ {len(chunks)} chunks indexed."
712
+ except Exception as e:
713
+ return f"❌ {e}"
714
+
715
+ build_btn.click(handle_build_kb, inputs=kb_files, outputs=kb_status)
716
+
717
+ # ── Upload DB ─────────────────────────────────────────────────
718
+ def handle_db_upload(file):
719
+ if file is None:
720
+ return "❌ No file."
721
+ import shutil
722
+ shutil.copy(file.name, DB_PATH)
723
+ return "✅ Database connected."
724
+
725
+ db_file.change(handle_db_upload, inputs=db_file, outputs=db_status)
726
+
727
+ # ── Clear memory ──────────────────────────────────────────────
728
+ def handle_clear(memory):
729
+ return ConversationMemory(), [], "Memory cleared."
730
+
731
+ clear_btn.click(
732
+ handle_clear,
733
+ inputs=[memory_state],
734
+ outputs=[memory_state, chat_history, mem_info],
735
+ )
736
+
737
+ # ── Language selection helpers ─────────────────────────────────
738
+ def _set_language(code, memory):
739
+ confirm_text = LANGUAGE_CONFIRMATION[code]
740
+ audio_path = tts_to_file(confirm_text, code)
741
+ badge = f"🗣️ Language: **{{'en':'English','ar':'العربية','ur':'اردو'}.get(code,code)}**"
742
+ # Return: lang_state, hide lang_group, show chat_group, badge, audio, memory
743
+ return (
744
+ code,
745
+ gr.update(visible=False), # lang_group
746
+ gr.update(visible=True), # chat_group
747
+ badge,
748
+ audio_path,
749
+ memory,
750
+ )
751
+
752
+ def handle_lang_mic(audio, memory):
753
+ if audio is None:
754
+ return ("", gr.update(), gr.update(), "", None, memory)
755
+ sr, data = audio
756
+ import io, soundfile as sf
757
+ buf = io.BytesIO()
758
+ sf.write(buf, data, sr, format="WAV")
759
+ transcript = transcribe_audio_bytes(buf.getvalue())
760
+ if not transcript:
761
+ return ("", gr.update(), gr.update(), "", None, memory)
762
+ detected = detect_lang(transcript)
763
+ if not detected:
764
+ return ("", gr.update(), gr.update(), "", None, memory)
765
+ return _set_language(detected, memory)
766
+
767
+ lang_mic.stop_recording(
768
+ handle_lang_mic,
769
+ inputs=[lang_mic, memory_state],
770
+ outputs=[lang_state, lang_group, chat_group,
771
+ lang_badge, lang_audio_out, memory_state],
772
+ )
773
+
774
+ for btn, code in [(btn_en, "en"), (btn_ar, "ar"), (btn_ur, "ur")]:
775
+ btn.click(
776
+ lambda mem, c=code: _set_language(c, mem),
777
+ inputs=[memory_state],
778
+ outputs=[lang_state, lang_group, chat_group,
779
+ lang_badge, lang_audio_out, memory_state],
780
+ )
781
+
782
+ # ── Change language ────────────────────────────────────────────
783
+ def handle_change():
784
+ return "", gr.update(visible=True), gr.update(visible=False)
785
+
786
+ change_btn.click(
787
+ handle_change,
788
+ outputs=[lang_state, lang_group, chat_group],
789
+ )
790
+
791
+ # ── Voice input ────────────────────────────────────────────────
792
+ def handle_voice(audio, lang_code, memory, history):
793
+ if audio is None:
794
+ return history, None, memory, ""
795
+ sr, data = audio
796
+ import io, soundfile as sf
797
+ buf = io.BytesIO()
798
+ sf.write(buf, data, sr, format="WAV")
799
+ raw = buf.getvalue()
800
+ if len(raw) < MIN_AUDIO_BYTES:
801
+ return history, None, memory, ""
802
+ transcript = transcribe_audio_bytes(raw)
803
+ if not transcript or len(transcript.strip()) < 3:
804
+ return history, None, memory, "⚠️ No clear speech detected."
805
+ _, new_history, audio_path = process_query(
806
+ transcript, memory, lang_code, history)
807
+ mem_info_txt = _mem_info(memory)
808
+ return new_history, audio_path, memory, mem_info_txt
809
+
810
+ voice_input.stop_recording(
811
+ handle_voice,
812
+ inputs=[voice_input, lang_state, memory_state, chat_history],
813
+ outputs=[chatbot, audio_out, memory_state, mem_info],
814
+ )
815
+
816
+ # ── Text input ────────────────────────────────────────────────
817
+ def handle_text(text, lang_code, memory, history):
818
+ if not text or not text.strip():
819
+ return history, None, memory, "", ""
820
+ _, new_history, audio_path = process_query(
821
+ text, memory, lang_code, history)
822
+ mem_info_txt = _mem_info(memory)
823
+ return new_history, audio_path, memory, mem_info_txt, ""
824
+
825
+ def _mem_info(memory: ConversationMemory) -> str:
826
+ ec = memory.entity_cache
827
+ parts = [f"Turns: {len(memory.turns)}"]
828
+ if ec["order_ids"]:
829
+ parts.append(f"Orders: {ec['order_ids']}")
830
+ if ec["customer_name"]:
831
+ parts.append(f"Name: {ec['customer_name']}")
832
+ return " | ".join(parts)
833
+
834
+ send_btn.click(
835
+ handle_text,
836
+ inputs=[txt_input, lang_state, memory_state, chat_history],
837
+ outputs=[chatbot, audio_out, memory_state, mem_info, txt_input],
838
+ )
839
+ txt_input.submit(
840
+ handle_text,
841
+ inputs=[txt_input, lang_state, memory_state, chat_history],
842
+ outputs=[chatbot, audio_out, memory_state, mem_info, txt_input],
843
+ )
844
+
845
+ return demo
846
+
847
+
848
+ # ══════════════════════════════════════════════════════════════════════════
849
+ # ENTRY POINT
850
+ # ══════════════════════════════════════════════════════════════════════════
851
 
852
  if __name__ == "__main__":
853
+ # Pre-warm models
854
+ print("⏳ Loading embedder…")
855
+ get_embedder()
856
+ print("⏳ Pre-loading FAISS index if available…")
857
+ get_index()
858
+
859
+ demo = build_app()
860
+ demo.launch(
861
+ server_name="0.0.0.0",
862
+ server_port=7860,
863
+ share=False,
864
+ )