rakib72642 commited on
Commit
bed58cc
ยท
1 Parent(s): 13425c7

fixed stt and added whisper and elevenlabs stt + updated

Browse files
Files changed (3) hide show
  1. app.py +13 -6
  2. core/backend.py +341 -64
  3. frontend/script.js +11 -10
app.py CHANGED
@@ -3,17 +3,15 @@ app.py โ€” FastAPI entrypoint: WebRTC-first + WebSocket fallback
3
 
4
  FIXES APPLIED:
5
  FIX-SESSION (Issue 1): The voice WS handler now reads user_id from the
6
- first 'init' JSON message before processing any audio. The variable is
7
- no longer a random UUID per connection โ€” it is the stable USER_ID sent
8
- by the browser from localStorage. This means every reconnect, even after
9
- a page reload, hits the same LangGraph thread and restores conversation
10
- history.
11
 
12
  Implementation:
13
  โ€ข user_id is initialised to None inside ws_voice.
14
  โ€ข The handler waits for any early text messages before processing binary.
15
  โ€ข On 'init' message, user_id is set and init_ack returned.
16
- โ€ข All subsequent audio/LLM calls use that stable user_id.
17
  โ€ข If no 'init' is received within 3 s, a random fallback is used
18
  (prevents hang for non-browser clients).
19
 
@@ -194,6 +192,11 @@ async def _safe_bytes(ws: WebSocket, data: bytes) -> bool:
194
  return False
195
 
196
 
 
 
 
 
 
197
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
198
  # WEBSOCKET โ€” CHAT (text only, streaming tokens)
199
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
@@ -223,6 +226,7 @@ async def ws_chat(ws: WebSocket):
223
  if claimed:
224
  user_id = claimed
225
  print(f"[CHAT] Session restored for user_id={user_id!r}")
 
226
  await _safe_text(ws, {"type": "init_ack", "user_id": user_id})
227
  continue
228
 
@@ -233,6 +237,7 @@ async def ws_chat(ws: WebSocket):
233
  # Fall back to user_id in message payload (compatibility)
234
  if not user_id:
235
  user_id = str(data.get("user_id", "default_user"))[:64]
 
236
 
237
  user_query = data.get("user_query", "").strip()
238
  if not user_query:
@@ -295,6 +300,7 @@ async def ws_voice(ws: WebSocket):
295
  else:
296
  print(f"[VOICE] Session user_id={user_id}")
297
 
 
298
  await _safe_text(ws, {"type": "init_ack", "user_id": user_id})
299
 
300
  stt = STTProcessor()
@@ -387,6 +393,7 @@ async def ws_voice(ws: WebSocket):
387
  claimed = str(msg.get("user_id", "")).strip()[:64]
388
  if claimed:
389
  user_id = claimed
 
390
  await _safe_text(ws, {"type": "init_ack", "user_id": user_id})
391
  elif t == "ping":
392
  await _safe_text(ws, {"type": "pong"})
 
3
 
4
  FIXES APPLIED:
5
  FIX-SESSION (Issue 1): The voice WS handler now reads user_id from the
6
+ first 'init' JSON message before processing any audio. The browser now
7
+ generates a fresh USER_ID on every page load, so each reload becomes a
8
+ brand-new user and gets a fresh LangGraph thread / DB row.
 
 
9
 
10
  Implementation:
11
  โ€ข user_id is initialised to None inside ws_voice.
12
  โ€ข The handler waits for any early text messages before processing binary.
13
  โ€ข On 'init' message, user_id is set and init_ack returned.
14
+ โ€ข All subsequent audio/LLM calls use that session user_id.
15
  โ€ข If no 'init' is received within 3 s, a random fallback is used
16
  (prevents hang for non-browser clients).
17
 
 
192
  return False
193
 
194
 
195
+ async def _register_user(user_id: str) -> None:
196
+ if user_id:
197
+ await ai.ensure_user_thread(user_id)
198
+
199
+
200
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
201
  # WEBSOCKET โ€” CHAT (text only, streaming tokens)
202
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
 
226
  if claimed:
227
  user_id = claimed
228
  print(f"[CHAT] Session restored for user_id={user_id!r}")
229
+ await _register_user(user_id)
230
  await _safe_text(ws, {"type": "init_ack", "user_id": user_id})
231
  continue
232
 
 
237
  # Fall back to user_id in message payload (compatibility)
238
  if not user_id:
239
  user_id = str(data.get("user_id", "default_user"))[:64]
240
+ await _register_user(user_id)
241
 
242
  user_query = data.get("user_query", "").strip()
243
  if not user_query:
 
300
  else:
301
  print(f"[VOICE] Session user_id={user_id}")
302
 
303
+ await _register_user(user_id)
304
  await _safe_text(ws, {"type": "init_ack", "user_id": user_id})
305
 
306
  stt = STTProcessor()
 
393
  claimed = str(msg.get("user_id", "")).strip()[:64]
394
  if claimed:
395
  user_id = claimed
396
+ await _register_user(user_id)
397
  await _safe_text(ws, {"type": "init_ack", "user_id": user_id})
398
  elif t == "ping":
399
  await _safe_text(ws, {"type": "pong"})
core/backend.py CHANGED
@@ -10,6 +10,7 @@ import aiosqlite
10
  import pytz
11
  from datetime import datetime, timedelta
12
  from dotenv import load_dotenv
 
13
 
14
  from langchain_core.messages import (
15
  AIMessage, AIMessageChunk, HumanMessage, RemoveMessage,
@@ -54,6 +55,143 @@ def format_bd_number(num: str) -> str:
54
  return num
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def send_sms(to_number: str, message: str) -> None:
58
  client = Client(os.getenv("TWILIO_ACCOUNT_SID"), os.getenv("TWILIO_AUTH_TOKEN"))
59
  client.messages.create(
@@ -124,6 +262,7 @@ async def get_categories_by_day(visiting_day: str = "") -> str:
124
 
125
  # Optional filter
126
  if visiting_day:
 
127
  query += " AND LOWER(visiting_days) LIKE ?"
128
  params.append(f"%{visiting_day.lower()}%")
129
 
@@ -174,6 +313,7 @@ async def get_doctors_by_day(visiting_day: str = "") -> str:
174
 
175
  # Optional filter
176
  if visiting_day:
 
177
  query += " AND LOWER(visiting_days) LIKE ?"
178
  params.append(f"%{visiting_day.lower()}%")
179
 
@@ -198,6 +338,63 @@ async def get_doctors_by_day(visiting_day: str = "") -> str:
198
  "data": doctors
199
  }, ensure_ascii=False)
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  @tool
202
  async def search_doctor(
203
  name: str = "",
@@ -209,19 +406,31 @@ async def search_doctor(
209
  Any combination of filters is supported (OR logic across fields).
210
  """
211
  db_path = get_db_path()
 
 
 
 
 
 
212
  query = "SELECT * FROM doctors WHERE 1=1"
213
  params: list = []
214
  conditions: list[str] = []
215
 
216
- if name:
217
- conditions.append("LOWER(doctor_name) LIKE ?")
218
- params.append(f"%{name.lower()}%")
219
- if category:
220
- conditions.append("LOWER(category) LIKE ?")
221
- params.append(f"%{category.lower()}%")
222
- if visiting_days:
 
 
 
 
 
 
223
  conditions.append("LOWER(visiting_days) LIKE ?")
224
- params.append(f"%{visiting_days.lower()}%")
225
 
226
  if conditions:
227
  query += " AND (" + " OR ".join(conditions) + ")"
@@ -232,9 +441,9 @@ async def search_doctor(
232
  rows = await cursor.fetchall()
233
 
234
  if not rows:
235
- return json.dumps({"success": False, "message": "No doctors found.", "data": []})
236
 
237
- return json.dumps({"success": True, "count": len(rows), "data": [dict(r) for r in rows]})
238
 
239
 
240
  @tool
@@ -262,33 +471,71 @@ async def search_appointment_by_phone(patient_num: str) -> str:
262
 
263
  @tool
264
  async def book_appointment(
265
- doctor_id: int,
266
- patient_name: str,
267
- patient_age: str,
268
- patient_num: str,
269
- visiting_date: str,
270
- patient_mail: str
 
 
271
  ) -> str:
272
  """
273
  Book a doctor appointment and save it to the patients table.
274
  Args:
275
- doctor_id: Doctor's ID from search_doctor results.
 
 
276
  patient_name: Full name of the patient.
277
  patient_age: Age of the patient (e.g. "32").
278
  patient_num: Contact phone number of the patient.
279
- visiting_date: Date of visit in YYYY-MM-DD format (e.g. 2025-06-15).
280
  patient_mail: Mail address for confirmation mail.
281
  """
282
  db_path = get_db_path()
283
  patient_num = format_bd_number(patient_num)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
  async with aiosqlite.connect(db_path) as db:
286
  db.row_factory = aiosqlite.Row
287
 
288
- cursor = await db.execute("SELECT * FROM doctors WHERE id = ?", (doctor_id,))
289
- doctor = await cursor.fetchone()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  if not doctor:
291
- return f"No doctor found with ID {doctor_id}. Please search for a doctor first."
 
 
 
292
 
293
  doctor_data = dict(doctor)
294
  doctor_name = doctor_data.get("doctor_name", "Unknown")
@@ -347,14 +594,21 @@ async def book_appointment(
347
 
348
 
349
  @tool
350
- async def delete_appointment(patient_num: str, doctor_name: str) -> str:
351
- """Delete an appointment using the patient's phone number and doctor name."""
352
  db_path = get_db_path()
353
  patient_num = format_bd_number(patient_num)
 
354
 
355
  async with aiosqlite.connect(db_path) as db:
356
  db.row_factory = aiosqlite.Row
357
 
 
 
 
 
 
 
358
  cursor = await db.execute(
359
  """SELECT * FROM patients
360
  WHERE patient_num = ? AND LOWER(doctor_name) = LOWER(?)""",
@@ -373,42 +627,54 @@ async def delete_appointment(patient_num: str, doctor_name: str) -> str:
373
  return json.dumps({
374
  "success": True,
375
  "message": f"Appointment with Dr. {doctor_name} deleted successfully.",
376
- })
377
 
378
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
379
  # SYSTEM PROMPT
380
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•๏ฟฝ๏ฟฝ๏ฟฝโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
381
  BASE_SYSTEM = """
382
- You are a Doctor Appointment Assistant AI.
383
- Your job is to help users manage medical appointments.
384
- CAPABILITIES:
385
- - Book doctor appointments
386
- - Reschedule appointments
387
- - Cancel appointments
388
- - Collect patient details
389
- STRICT RULES:
 
 
 
390
  - You are NOT a doctor.
391
- - NEVER diagnose diseases.
392
- - NEVER recommend medicines or treatments.
 
393
  APPOINTMENT FLOW:
394
- 1. Detect intent (book / cancel / reschedule / inquiry)
395
- 2. Collect details
396
- 3. Confirm all details before final booking
397
- STYLE:
398
- - Be short, clear, structured
399
- - Focus on completing booking
 
 
 
 
 
 
400
  LANGUAGE RULE:
401
- - Detect user language from latest message.
402
- - If English โ†’ reply English.
403
- - If Bangla โ†’ reply Bangla (เฆฌเฆพเฆ‚เฆฒเฆพ).
404
- - If Banglish โ†’ reply Bangla (เฆฌเฆพเฆ‚เฆฒเฆพ).
405
- - Never mix languages unless user mixes first.
406
- DOCTOR ID RULE:
407
- - Never generate or guess doctor_id.
408
- - doctor_id must only come from search_doctor tool output.
409
- TOOLS:
410
- - Use backend tools if needed
411
- - Always confirm before final action
 
 
412
  """
413
 
414
  SUMMARY_SYSTEM = (
@@ -443,6 +709,7 @@ class AIBackend:
443
  self.llm = ChatOllama(model="gemma4:e4b", streaming=True, temperature=0.01)
444
 
445
  self.tools = [
 
446
  search_doctor,
447
  book_appointment,
448
  get_bd_time,
@@ -614,23 +881,33 @@ class AIBackend:
614
  threads.add(cp.config["configurable"]["thread_id"])
615
  return list(threads)
616
 
617
- # โ”€โ”€ Public entry point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
618
- async def main(self, user_id: str, user_query: str) -> AsyncGenerator[str, None]:
619
- """Return an async generator of AI text tokens."""
 
 
 
620
  async with self.conn.execute(
621
- "SELECT threadId FROM userid_threadid WHERE userId = ?", (user_id,)
 
622
  ) as cursor:
623
  row = await cursor.fetchone()
624
 
625
- if row is None:
626
- thread_id = user_id + self.generate_thread_id()
627
- await self.conn.execute(
628
- "INSERT INTO userid_threadid (userId, threadId) VALUES (?, ?)",
629
- (user_id, thread_id),
630
- )
631
- await self.conn.commit()
632
- else:
633
- thread_id = row[0]
 
 
 
 
 
 
634
 
635
  initial_state = {"messages": [HumanMessage(content=user_query)]}
636
  config = {
 
10
  import pytz
11
  from datetime import datetime, timedelta
12
  from dotenv import load_dotenv
13
+ import re
14
 
15
  from langchain_core.messages import (
16
  AIMessage, AIMessageChunk, HumanMessage, RemoveMessage,
 
55
  return num
56
 
57
 
58
+ def _clean_text(text: str) -> str:
59
+ return re.sub(r"\s+", " ", (text or "").strip())
60
+
61
+
62
+ DAY_ALIASES = {
63
+ "sunday": "Sunday",
64
+ "monday": "Monday",
65
+ "tuesday": "Tuesday",
66
+ "wednesday": "Wednesday",
67
+ "thursday": "Thursday",
68
+ "friday": "Friday",
69
+ "saturday": "Saturday",
70
+ "เฆฐเฆฌเฆฟเฆฌเฆพเฆฐ": "Sunday",
71
+ "เฆธเง‹เฆฎเฆฌเฆพเฆฐ": "Monday",
72
+ "เฆฎเฆ™เงเฆ—เฆฒเฆฌเฆพเฆฐ": "Tuesday",
73
+ "เฆฌเงเฆงเฆฌเฆพเฆฐ": "Wednesday",
74
+ "เฆฌเงƒเฆนเฆธเงเฆชเฆคเฆฟเฆฌเฆพเฆฐ": "Thursday",
75
+ "เฆถเงเฆ•เงเฆฐเฆฌเฆพเฆฐ": "Friday",
76
+ "เฆถเฆจเฆฟเฆฌเฆพเฆฐ": "Saturday",
77
+ }
78
+
79
+ SPECIALTY_ALIASES = {
80
+ "เฆšเฆ•เงเฆทเง": ["ophthalmologist", "eye specialist", "eye doctor", "ophthalmology", "eye"],
81
+ "เฆ†เฆ‡": ["ophthalmologist", "eye specialist", "eye doctor", "ophthalmology", "eye"],
82
+ "เฆšเง‹เฆ–": ["ophthalmologist", "eye specialist", "eye doctor", "ophthalmology", "eye"],
83
+ "เฆนเงƒเฆฆเฆฐเง‹เฆ—": ["cardiologist", "heart", "cardio", "cardiology"],
84
+ "เฆ•เฆพเฆฐเงเฆกเฆฟเฆ“": ["cardiologist", "heart", "cardio", "cardiology"],
85
+ "เฆฎเง‡เฆกเฆฟเฆธเฆฟเฆจ": ["medicine", "internal medicine", "physician", "general medicine"],
86
+ "เฆจเฆฟเฆ‰เฆฐเง‹": ["neurologist", "neurology", "brain"],
87
+ "เฆธเงเฆจเฆพเฆฏเฆผเง": ["neurologist", "neurology", "brain"],
88
+ "เฆจเฆพเฆ•": ["ent", "otolaryngologist", "ear nose throat"],
89
+ "เฆ•เฆพเฆจ": ["ent", "otolaryngologist", "ear nose throat"],
90
+ "เฆ—เฆฒเฆพ": ["ent", "otolaryngologist", "ear nose throat"],
91
+ "เฆšเฆฐเงเฆฎ": ["dermatologist", "skin", "dermatology"],
92
+ "เฆธเงเฆ•เฆฟเฆจ": ["dermatologist", "skin", "dermatology"],
93
+ "เฆกเง‡เฆจเงเฆŸเฆพเฆฒ": ["dentist", "dental", "teeth"],
94
+ "เฆฆเฆพเฆเฆค": ["dentist", "dental", "teeth"],
95
+ "เฆ—เฆพเฆ‡เฆจเง€": ["gynecologist", "gynaecologist", "obgyn", "women"],
96
+ "เฆฎเฆนเฆฟเฆฒเฆพ": ["gynecologist", "gynaecologist", "obgyn", "women"],
97
+ "เฆถเฆฟเฆถเง": ["pediatrician", "child", "children"],
98
+ "เฆชเง‡เฆกเฆฟเฆฏเฆผเฆพเฆŸเงเฆฐเฆฟเฆ•": ["pediatrician", "child", "children"],
99
+ "เฆ…เฆฐเงเฆฅเง‹": ["orthopedic", "orthopaedic", "bone"],
100
+ "เฆนเฆพเฆกเฆผ": ["orthopedic", "orthopaedic", "bone"],
101
+ "เฆฌเฆ•เงเฆท": ["chest", "pulmonologist", "respiratory"],
102
+ "เฆถเงเฆฌเฆพเฆธ": ["pulmonologist", "respiratory", "chest"],
103
+ "เฆ•เฆฟเฆกเฆจเฆฟ": ["nephrologist", "kidney", "renal"],
104
+ "เฆ—เงเฆฏเฆพเฆธเงเฆŸเงเฆฐเง‹": ["gastroenterologist", "stomach", "digestive"],
105
+ "เฆชเง‡เฆŸ": ["gastroenterologist", "stomach", "digestive"],
106
+ }
107
+
108
+
109
+ def _normalize_day(term: str) -> str:
110
+ raw = _clean_text(term)
111
+ if not raw:
112
+ return ""
113
+ lower = raw.lower()
114
+ return DAY_ALIASES.get(lower, DAY_ALIASES.get(raw, raw))
115
+
116
+
117
+ def _expand_search_terms(text: str) -> list[str]:
118
+ """
119
+ Expand Bangla/Banglish doctor-search text into English-friendly terms.
120
+ """
121
+ raw = _clean_text(text)
122
+ if not raw:
123
+ return []
124
+
125
+ terms: set[str] = {raw.lower()}
126
+ raw_lower = raw.lower()
127
+
128
+ for bangla_key, aliases in SPECIALTY_ALIASES.items():
129
+ if bangla_key in raw or bangla_key.lower() in raw_lower:
130
+ terms.update(a.lower() for a in aliases)
131
+
132
+ if raw_lower in DAY_ALIASES:
133
+ terms.add(DAY_ALIASES[raw_lower].lower())
134
+
135
+ # Keep the individual tokens too, because users may mix Bangla and English.
136
+ for token in re.split(r"[,\s/|]+", raw_lower):
137
+ token = token.strip()
138
+ if token:
139
+ terms.add(token)
140
+
141
+ return sorted(terms)
142
+
143
+
144
+ def _parse_visit_date(text: str) -> Optional[str]:
145
+ """
146
+ Parse a user-facing date into YYYY-MM-DD in Bangladesh time.
147
+ Accepts ISO, English relative dates, and many natural-language variants.
148
+ """
149
+ text = _clean_text(text)
150
+ if not text:
151
+ return None
152
+
153
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
154
+ return text
155
+
156
+ tz = pytz.timezone("Asia/Dhaka")
157
+ now = datetime.now(tz)
158
+
159
+ lower = text.lower()
160
+ if text in {"เฆ†เฆœ", "today"}:
161
+ return now.strftime("%Y-%m-%d")
162
+ if text in {"เฆ†เฆ—เฆพเฆฎเง€เฆ•เฆพเฆฒ", "tomorrow"}:
163
+ return (now + timedelta(days=1)).strftime("%Y-%m-%d")
164
+ if text in {"เฆชเฆฐเฆถเง", "day after tomorrow"}:
165
+ return (now + timedelta(days=2)).strftime("%Y-%m-%d")
166
+
167
+ day_name = _normalize_day(text)
168
+ if day_name in DAY_ALIASES.values():
169
+ target_idx = [
170
+ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"
171
+ ].index(day_name)
172
+ current_idx = now.weekday()
173
+ delta = (target_idx - current_idx) % 7
174
+ if delta == 0:
175
+ delta = 7
176
+ return (now + timedelta(days=delta)).strftime("%Y-%m-%d")
177
+
178
+ try:
179
+ found = search_dates(
180
+ text,
181
+ settings={
182
+ "PREFER_DATES_FROM": "future",
183
+ "TIMEZONE": "Asia/Dhaka",
184
+ "RETURN_AS_TIMEZONE_AWARE": False,
185
+ "RELATIVE_BASE": now.replace(tzinfo=None),
186
+ },
187
+ )
188
+ if found:
189
+ return found[0][1].strftime("%Y-%m-%d")
190
+ except Exception:
191
+ pass
192
+ return None
193
+
194
+
195
  def send_sms(to_number: str, message: str) -> None:
196
  client = Client(os.getenv("TWILIO_ACCOUNT_SID"), os.getenv("TWILIO_AUTH_TOKEN"))
197
  client.messages.create(
 
262
 
263
  # Optional filter
264
  if visiting_day:
265
+ visiting_day = _normalize_day(visiting_day)
266
  query += " AND LOWER(visiting_days) LIKE ?"
267
  params.append(f"%{visiting_day.lower()}%")
268
 
 
313
 
314
  # Optional filter
315
  if visiting_day:
316
+ visiting_day = _normalize_day(visiting_day)
317
  query += " AND LOWER(visiting_days) LIKE ?"
318
  params.append(f"%{visiting_day.lower()}%")
319
 
 
338
  "data": doctors
339
  }, ensure_ascii=False)
340
 
341
+ @tool
342
+ async def find_doctors(query: str = "", visiting_day: str = "") -> str:
343
+ """
344
+ Flexible doctor search for Bangla, Banglish, or English queries.
345
+
346
+ Use this for questions like:
347
+ - "เฆšเฆ•เงเฆทเง เฆกเฆพเฆ•เงเฆคเฆพเฆฐ"
348
+ - "เฆ†เฆœ เฆ•เง‹เฆจ cardiologist เฆ†เฆ›เง‡?"
349
+ - "เฆฎเฆ™เงเฆ—เฆฒเฆฌเฆพเฆฐ available pediatric doctor"
350
+ """
351
+ db_path = get_db_path()
352
+ query_text = _clean_text(query)
353
+ day_text = _normalize_day(visiting_day)
354
+ terms = _expand_search_terms(query_text)
355
+
356
+ sql = "SELECT * FROM doctors WHERE 1=1"
357
+ params: list[str] = []
358
+ conditions: list[str] = []
359
+
360
+ if day_text:
361
+ conditions.append("LOWER(visiting_days) LIKE ?")
362
+ params.append(f"%{day_text.lower()}%")
363
+
364
+ if terms:
365
+ term_clauses = []
366
+ for term in terms:
367
+ term_clauses.append("(LOWER(doctor_name) LIKE ? OR LOWER(category) LIKE ? OR LOWER(visiting_days) LIKE ?)")
368
+ params.extend([f"%{term}%", f"%{term}%", f"%{term}%"])
369
+ conditions.append("(" + " OR ".join(term_clauses) + ")")
370
+
371
+ if conditions:
372
+ sql += " AND " + " AND ".join(conditions)
373
+
374
+ async with aiosqlite.connect(db_path) as db:
375
+ db.row_factory = aiosqlite.Row
376
+ cursor = await db.execute(sql, params)
377
+ rows = await cursor.fetchall()
378
+
379
+ if not rows:
380
+ return json.dumps({
381
+ "success": False,
382
+ "message": "No doctors found.",
383
+ "query": query_text,
384
+ "visiting_day": day_text or "ALL",
385
+ "data": [],
386
+ }, ensure_ascii=False)
387
+
388
+ doctors = [dict(row) for row in rows]
389
+ return json.dumps({
390
+ "success": True,
391
+ "count": len(doctors),
392
+ "query": query_text,
393
+ "visiting_day": day_text or "ALL",
394
+ "data": doctors,
395
+ }, ensure_ascii=False)
396
+
397
+
398
  @tool
399
  async def search_doctor(
400
  name: str = "",
 
406
  Any combination of filters is supported (OR logic across fields).
407
  """
408
  db_path = get_db_path()
409
+ name = _clean_text(name)
410
+ category = _clean_text(category)
411
+ visiting_days = _clean_text(visiting_days)
412
+ name_terms = _expand_search_terms(name)
413
+ category_terms = _expand_search_terms(category)
414
+ day_text = _normalize_day(visiting_days) if visiting_days else ""
415
  query = "SELECT * FROM doctors WHERE 1=1"
416
  params: list = []
417
  conditions: list[str] = []
418
 
419
+ if name_terms:
420
+ name_clauses = []
421
+ for term in name_terms:
422
+ name_clauses.append("LOWER(doctor_name) LIKE ?")
423
+ params.append(f"%{term}%")
424
+ conditions.append("(" + " OR ".join(name_clauses) + ")")
425
+ if category_terms:
426
+ category_clauses = []
427
+ for term in category_terms:
428
+ category_clauses.append("LOWER(category) LIKE ?")
429
+ params.append(f"%{term}%")
430
+ conditions.append("(" + " OR ".join(category_clauses) + ")")
431
+ if day_text:
432
  conditions.append("LOWER(visiting_days) LIKE ?")
433
+ params.append(f"%{day_text.lower()}%")
434
 
435
  if conditions:
436
  query += " AND (" + " OR ".join(conditions) + ")"
 
441
  rows = await cursor.fetchall()
442
 
443
  if not rows:
444
+ return json.dumps({"success": False, "message": "No doctors found.", "data": []}, ensure_ascii=False)
445
 
446
+ return json.dumps({"success": True, "count": len(rows), "data": [dict(r) for r in rows]}, ensure_ascii=False)
447
 
448
 
449
  @tool
 
471
 
472
  @tool
473
  async def book_appointment(
474
+ doctor_id: int = 0,
475
+ doctor_name: str = "",
476
+ category: str = "",
477
+ patient_name: str = "",
478
+ patient_age: str = "",
479
+ patient_num: str = "",
480
+ visiting_date: str = "",
481
+ patient_mail: str = ""
482
  ) -> str:
483
  """
484
  Book a doctor appointment and save it to the patients table.
485
  Args:
486
+ doctor_id: Doctor's ID from search_doctor results (preferred).
487
+ doctor_name: Doctor name if doctor_id is not available.
488
+ category: Optional doctor category if doctor_id is not available.
489
  patient_name: Full name of the patient.
490
  patient_age: Age of the patient (e.g. "32").
491
  patient_num: Contact phone number of the patient.
492
+ visiting_date: Date of visit in YYYY-MM-DD format or natural text.
493
  patient_mail: Mail address for confirmation mail.
494
  """
495
  db_path = get_db_path()
496
  patient_num = format_bd_number(patient_num)
497
+ patient_name = _clean_text(patient_name)
498
+ patient_age = _clean_text(patient_age)
499
+ doctor_name = _clean_text(doctor_name)
500
+ category = _clean_text(category)
501
+ visiting_date = _clean_text(visiting_date)
502
+ parsed_date = _parse_visit_date(visiting_date)
503
+ if parsed_date:
504
+ visiting_date = parsed_date
505
+
506
+ if not patient_name or not patient_age or not patient_num or not visiting_date or not patient_mail:
507
+ return (
508
+ "Missing booking details. Need patient name, age, phone number, "
509
+ "visit date, and email."
510
+ )
511
 
512
  async with aiosqlite.connect(db_path) as db:
513
  db.row_factory = aiosqlite.Row
514
 
515
+ doctor = None
516
+ if doctor_id:
517
+ cursor = await db.execute("SELECT * FROM doctors WHERE id = ?", (doctor_id,))
518
+ doctor = await cursor.fetchone()
519
+
520
+ if doctor is None and doctor_name:
521
+ cursor = await db.execute(
522
+ "SELECT * FROM doctors WHERE LOWER(doctor_name) = LOWER(?)",
523
+ (doctor_name,),
524
+ )
525
+ doctor = await cursor.fetchone()
526
+
527
+ if doctor is None and category:
528
+ cursor = await db.execute(
529
+ "SELECT * FROM doctors WHERE LOWER(category) LIKE ? ORDER BY id LIMIT 1",
530
+ (f"%{category.lower()}%",),
531
+ )
532
+ doctor = await cursor.fetchone()
533
+
534
  if not doctor:
535
+ return (
536
+ "No doctor found. Please search first and provide either "
537
+ "doctor_id, doctor_name, or category."
538
+ )
539
 
540
  doctor_data = dict(doctor)
541
  doctor_name = doctor_data.get("doctor_name", "Unknown")
 
594
 
595
 
596
  @tool
597
+ async def delete_appointment(patient_num: str, doctor_name: str = "", doctor_id: int = 0) -> str:
598
+ """Delete an appointment using the patient's phone number and doctor name or ID."""
599
  db_path = get_db_path()
600
  patient_num = format_bd_number(patient_num)
601
+ doctor_name = _clean_text(doctor_name)
602
 
603
  async with aiosqlite.connect(db_path) as db:
604
  db.row_factory = aiosqlite.Row
605
 
606
+ if not doctor_name and doctor_id:
607
+ cursor = await db.execute("SELECT doctor_name FROM doctors WHERE id = ?", (doctor_id,))
608
+ row = await cursor.fetchone()
609
+ if row:
610
+ doctor_name = row["doctor_name"]
611
+
612
  cursor = await db.execute(
613
  """SELECT * FROM patients
614
  WHERE patient_num = ? AND LOWER(doctor_name) = LOWER(?)""",
 
627
  return json.dumps({
628
  "success": True,
629
  "message": f"Appointment with Dr. {doctor_name} deleted successfully.",
630
+ }, ensure_ascii=False)
631
 
632
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
633
  # SYSTEM PROMPT
634
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•๏ฟฝ๏ฟฝ๏ฟฝโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
635
  BASE_SYSTEM = """
636
+ You are DAA, a warm Bangla-first medical appointment concierge.
637
+ Your job is to help people find doctors, check availability, and manage appointments.
638
+
639
+ CORE BEHAVIOR:
640
+ - Speak naturally and politely like a human assistant.
641
+ - Default to Bangla when the user speaks Bangla or Banglish.
642
+ - Keep replies short, helpful, and one step at a time.
643
+ - If the database fields are English, translate the user's Bangla intent into English before calling tools.
644
+ - Never answer doctor availability or booking questions from memory when a tool can verify it.
645
+
646
+ STRICT SAFETY:
647
  - You are NOT a doctor.
648
+ - Never diagnose diseases.
649
+ - Never recommend medicines or treatments.
650
+
651
  APPOINTMENT FLOW:
652
+ 1. Understand the user's intent.
653
+ 2. Use tools to find the right doctor or appointment record.
654
+ 3. Ask only for missing details.
655
+ 4. Confirm important details before booking or deleting.
656
+
657
+ TOOL RULES:
658
+ - Use `find_doctors` first for doctor search, specialty search, and availability search.
659
+ - Use `get_doctors_by_day` or `get_categories_by_day` when the user asks about a day directly.
660
+ - Use `book_appointment` only after identifying the doctor and required patient details.
661
+ - Never invent `doctor_id`. Get it from tool results or resolve by doctor_name/category.
662
+ - If the user gives a Bangla date like "เฆ†เฆ—เฆพเฆฎเง€เฆ•เฆพเฆฒ" or "เฆชเฆฐเฆถเง", convert it to a real date before booking.
663
+
664
  LANGUAGE RULE:
665
+ - Respond in the userโ€™s language.
666
+ - If the user uses Bangla, reply in clear Bangla.
667
+ - If the user uses Banglish, reply in Bangla unless they clearly prefer English.
668
+ - Always generate numbers in english
669
+
670
+ DATA RULE:
671
+ - Doctor names, categories, and days in the database are English.
672
+ - Bangla terms such as เฆšเฆ•เงเฆทเง/เฆ•เฆพเฆฐเงเฆกเฆฟเฆ“/เฆถเฆฟเฆถเง/เฆšเฆฐเงเฆฎ must be translated to English search terms before tool calls.
673
+
674
+ RESPONSE STYLE:
675
+ - Be concise.
676
+ - Be reassuring.
677
+ - Ask one clear question when more information is needed.
678
  """
679
 
680
  SUMMARY_SYSTEM = (
 
709
  self.llm = ChatOllama(model="gemma4:e4b", streaming=True, temperature=0.01)
710
 
711
  self.tools = [
712
+ find_doctors,
713
  search_doctor,
714
  book_appointment,
715
  get_bd_time,
 
881
  threads.add(cp.config["configurable"]["thread_id"])
882
  return list(threads)
883
 
884
+ async def ensure_user_thread(self, user_id: str) -> str:
885
+ """Create a DB-backed thread for a user if it does not already exist."""
886
+ user_id = _clean_text(user_id)[:64]
887
+ if not user_id:
888
+ raise ValueError("user_id is required")
889
+
890
  async with self.conn.execute(
891
+ "SELECT threadId FROM userid_threadid WHERE userId = ?",
892
+ (user_id,),
893
  ) as cursor:
894
  row = await cursor.fetchone()
895
 
896
+ if row is not None:
897
+ return row[0]
898
+
899
+ thread_id = user_id + self.generate_thread_id()
900
+ await self.conn.execute(
901
+ "INSERT INTO userid_threadid (userId, threadId) VALUES (?, ?)",
902
+ (user_id, thread_id),
903
+ )
904
+ await self.conn.commit()
905
+ return thread_id
906
+
907
+ # โ”€โ”€ Public entry point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€๏ฟฝ๏ฟฝ๏ฟฝโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
908
+ async def main(self, user_id: str, user_query: str) -> AsyncGenerator[str, None]:
909
+ """Return an async generator of AI text tokens."""
910
+ thread_id = await self.ensure_user_thread(user_id)
911
 
912
  initial_state = {"messages": [HumanMessage(content=user_query)]}
913
  config = {
frontend/script.js CHANGED
@@ -36,18 +36,18 @@ const mTts = document.getElementById('m-tts');
36
  const mTotal = document.getElementById('m-total');
37
  const sysStat = document.getElementById('sys-status');
38
 
39
- // โ”€โ”€โ”€ Persistent user identity โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
40
  const USER_ID = (() => {
41
- let id = localStorage.getItem('daa_uid');
42
- if (!id) {
43
- id =
44
- 'u_' +
45
- Date.now().toString(36) +
46
- '_' +
47
- Math.random().toString(36).slice(2, 6);
48
- localStorage.setItem('daa_uid', id);
49
  }
50
- return id;
 
 
 
 
 
51
  })();
52
 
53
  // โ”€โ”€โ”€ WebSocket base URL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -192,6 +192,7 @@ function _connectChat() {
192
  chatWS.onopen = () => {
193
  _chatRetry = 0;
194
  console.log('[Chat WS] connected');
 
195
  };
196
  chatWS.onerror = (e) => console.error('[Chat WS] error:', e);
197
  chatWS.onclose = (ev) => {
 
36
  const mTotal = document.getElementById('m-total');
37
  const sysStat = document.getElementById('sys-status');
38
 
39
+ // โ”€โ”€โ”€ Ephemeral user identity โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
40
+ // New page load = new user. Reloading the app generates a fresh ID.
41
  const USER_ID = (() => {
42
+ if (window.crypto && typeof window.crypto.randomUUID === 'function') {
43
+ return 'u_' + window.crypto.randomUUID().replace(/-/g, '').slice(0, 16);
 
 
 
 
 
 
44
  }
45
+ return (
46
+ 'u_' +
47
+ Date.now().toString(36) +
48
+ '_' +
49
+ Math.random().toString(36).slice(2, 10)
50
+ );
51
  })();
52
 
53
  // โ”€โ”€โ”€ WebSocket base URL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
192
  chatWS.onopen = () => {
193
  _chatRetry = 0;
194
  console.log('[Chat WS] connected');
195
+ chatWS.send(JSON.stringify({ type: 'init', user_id: USER_ID }));
196
  };
197
  chatWS.onerror = (e) => console.error('[Chat WS] error:', e);
198
  chatWS.onclose = (ev) => {