Hamdy005 commited on
Commit
3b6b3bd
·
1 Parent(s): 8743a99

feat: implement WebSocket-based streaming chat

Browse files
Files changed (3) hide show
  1. main.py +2 -1
  2. rag/rag.py +135 -0
  3. rag/routes.py +195 -3
main.py CHANGED
@@ -16,7 +16,7 @@ from fastapi.responses import RedirectResponse
16
 
17
  from src.materials.routes import router as materials_router
18
  from src.summary_generator.routes import router as summary_router
19
- from src.rag.routes import router as tutor_router
20
  from src.quiz_generator.routes import router as quiz_router
21
  from src.auth.routes import router as auth_router
22
  from src.asr.routes import router as asr_router
@@ -132,6 +132,7 @@ app.add_middleware(
132
  app.include_router(materials_router)
133
  app.include_router(summary_router)
134
  app.include_router(tutor_router)
 
135
  app.include_router(quiz_router)
136
  app.include_router(auth_router)
137
  app.include_router(asr_router)
 
16
 
17
  from src.materials.routes import router as materials_router
18
  from src.summary_generator.routes import router as summary_router
19
+ from src.rag.routes import router as tutor_router, ws_router
20
  from src.quiz_generator.routes import router as quiz_router
21
  from src.auth.routes import router as auth_router
22
  from src.asr.routes import router as asr_router
 
132
  app.include_router(materials_router)
133
  app.include_router(summary_router)
134
  app.include_router(tutor_router)
135
+ app.include_router(ws_router)
136
  app.include_router(quiz_router)
137
  app.include_router(auth_router)
138
  app.include_router(asr_router)
rag/rag.py CHANGED
@@ -450,6 +450,141 @@ def rag_answer(
450
  logger.error(f"Fallback LLM call also failed: {fallback_err}")
451
  raise fallback_err
452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  def extract_chat_title(query: str, material_title: Optional[str] = None) -> str:
454
  topic_context = ""
455
  if material_title:
 
450
  logger.error(f"Fallback LLM call also failed: {fallback_err}")
451
  raise fallback_err
452
 
453
+
454
+ async def rag_answer_stream(
455
+ query: str,
456
+ material_id: Optional[str] = None,
457
+ chunks: Optional[list[str]] = None,
458
+ summaries: str = "",
459
+ memory = None,
460
+ ):
461
+ """
462
+ Async generator that streams LLM response tokens using Mistral AI (with Gemini fallback).
463
+ Yields individual token strings as they arrive.
464
+ Updates conversation memory upon completion if not a refusal.
465
+ """
466
+ if memory is None:
467
+ memory = ConversationBufferWindowMemory(
468
+ input_key="input", memory_key="chat_history", return_messages=True, k=MEMORY_WINDOW_SIZE
469
+ )
470
+
471
+ # Fetch material info if material_id is provided
472
+ mat = None
473
+ if material_id:
474
+ mat = await asyncio.to_thread(get_material, material_id)
475
+
476
+ is_topic = not (material_id and mat and mat.get("source_type") != "topic")
477
+
478
+ context_parts = []
479
+ has_chunks = False
480
+
481
+ # Inject Subject/Topic
482
+ if mat and mat.get("title"):
483
+ context_parts.append(f"Subject / Topic: {mat.get('title')}")
484
+
485
+ if not is_topic:
486
+ # --- Material-based query (PDF/URL): vector similarity search ---
487
+ results = await asyncio.to_thread(similarity_search, query, material_id, k=TOP_K_CHUNKS)
488
+ if results:
489
+ has_chunks = True
490
+ chunks = [r["content"] for r in results]
491
+ context_parts.append("Relevant Excerpts:\n" + "\n---\n".join(chunks))
492
+
493
+ # Fallback: summary
494
+ if not has_chunks and summaries:
495
+ context_parts.append(f"Material Summary (No specific excerpts found for your query):\n{summaries}")
496
+
497
+ # Fallback: sample head + tail chunks
498
+ if not has_chunks and not summaries:
499
+ all_chunks = await asyncio.to_thread(get_chunks, material_id)
500
+ if all_chunks:
501
+ head = all_chunks[:3]
502
+ tail = all_chunks[-2:] if len(all_chunks) > 3 else []
503
+ sampled = head + [c for c in tail if c not in head]
504
+ sampled_text = "\n---\n".join(c["content"] for c in sampled)
505
+ context_parts.append(f"Material Sample (No summary found; showing start and end of material):\n{sampled_text}")
506
+
507
+ # --- Wikipedia search: topics only ---
508
+ wiki_snippets = ""
509
+ if is_topic:
510
+ wiki_snippets = await asyncio.to_thread(direct_wiki_search, query)
511
+ if wiki_snippets:
512
+ context_parts.append(f"Wikipedia Results:\n{wiki_snippets}")
513
+
514
+ # --- DuckDuckGo search: ALL material types (topics, PDFs, URLs) ---
515
+ subject_title = mat.get("title") if mat and mat.get("title") else ""
516
+ ddg_query = f"{query} {subject_title}".strip() if subject_title else query
517
+ ddg_snippets = await asyncio.to_thread(direct_ddg_search, ddg_query)
518
+ if ddg_snippets:
519
+ context_parts.append(f"Web Search Results (DuckDuckGo):\n{ddg_snippets}")
520
+
521
+ context_str = "\n\n".join(context_parts) if context_parts else "No specific context provided."
522
+
523
+ has_knowledge = not is_topic
524
+ subject_title = mat.get("title") if mat and mat.get("title") else ""
525
+ prompt = _rag_prompt(
526
+ has_ddg=bool(ddg_snippets),
527
+ has_wiki=bool(wiki_snippets),
528
+ has_knowledge_retriever=has_knowledge,
529
+ subject=subject_title,
530
+ )
531
+
532
+ _REFUSAL_PREFIXES = (
533
+ "I can't respond on a gibberish",
534
+ "I can't respond on a NSFW",
535
+ "I can't respond on a political",
536
+ "I can't respond on a religious",
537
+ )
538
+
539
+ def _is_refusal(text: str) -> bool:
540
+ t = text.strip()
541
+ return any(t.startswith(p) for p in _REFUSAL_PREFIXES)
542
+
543
+ memory_vars = memory.load_memory_variables({"input": query})
544
+ chat_history = memory_vars.get("chat_history", [])
545
+
546
+ full_answer_parts = []
547
+
548
+ try:
549
+ primary_llm = get_llm()
550
+ chain = prompt | primary_llm
551
+
552
+ async for chunk in chain.astream({
553
+ "input": query,
554
+ "context": context_str,
555
+ "chat_history": chat_history,
556
+ "agent_scratchpad": "",
557
+ }):
558
+ token = _clean_llm_response(chunk.content)
559
+ if token:
560
+ full_answer_parts.append(token)
561
+ yield token
562
+
563
+ except Exception as e:
564
+ logger.warning(f"Primary LLM streaming failed or rate-limited: {e}. Falling back to secondary LLM.")
565
+ try:
566
+ fallback_llm = get_fallback_llm()
567
+ chain = prompt | fallback_llm
568
+
569
+ async for chunk in chain.astream({
570
+ "input": query,
571
+ "context": context_str,
572
+ "chat_history": chat_history,
573
+ "agent_scratchpad": "",
574
+ }):
575
+ token = _clean_llm_response(chunk.content)
576
+ if token:
577
+ full_answer_parts.append(token)
578
+ yield token
579
+ except Exception as fallback_err:
580
+ logger.error(f"Fallback LLM streaming failed: {fallback_err}")
581
+ raise fallback_err
582
+
583
+ full_answer = "".join(full_answer_parts)
584
+ if not _is_refusal(full_answer):
585
+ memory.save_context({"input": query}, {"output": full_answer})
586
+
587
+
588
  def extract_chat_title(query: str, material_title: Optional[str] = None) -> str:
589
  topic_context = ""
590
  if material_title:
rag/routes.py CHANGED
@@ -1,12 +1,16 @@
1
  import time
 
2
  import asyncio
 
3
  from loguru import logger
4
- from fastapi import APIRouter, HTTPException, Depends
5
  from typing import Optional, Any
6
 
7
- from src.rag.rag import rag_answer, extract_chat_title
 
 
8
  from src.rag.constants import REFUSAL_PREFIXES
9
- from src.dependencies import get_current_user, get_current_user_id
10
  from src.store import (
11
  get_material, get_chunks, get_summary, get_or_create_memory, append_memory_message,
12
  # Session-based chat
@@ -21,6 +25,194 @@ from src.summary_generator.summary import clean_summary
21
  from .schemas import TutorQuery, TutorResponse, SessionRequest, RenameSessionRequest, ExtractTitleRequest, SaveChatRequest
22
 
23
  router = APIRouter(prefix="/api/tutor", tags=["Tutor"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
 
26
  @router.post("/ask", response_model=TutorResponse)
 
1
  import time
2
+ import json
3
  import asyncio
4
+ import jwt as pyjwt
5
  from loguru import logger
6
+ from fastapi import APIRouter, HTTPException, Depends, WebSocket, WebSocketDisconnect, status
7
  from typing import Optional, Any
8
 
9
+ from src.config import settings
10
+ from src.database import get_supabase, get_auth_supabase
11
+ from src.rag.rag import rag_answer, extract_chat_title, rag_answer_stream
12
  from src.rag.constants import REFUSAL_PREFIXES
13
+ from src.dependencies import get_current_user, get_current_user_id, DEV_USER_ID, _verify_token_cached
14
  from src.store import (
15
  get_material, get_chunks, get_summary, get_or_create_memory, append_memory_message,
16
  # Session-based chat
 
25
  from .schemas import TutorQuery, TutorResponse, SessionRequest, RenameSessionRequest, ExtractTitleRequest, SaveChatRequest
26
 
27
  router = APIRouter(prefix="/api/tutor", tags=["Tutor"])
28
+ ws_router = APIRouter(tags=["WebSocket Chat"])
29
+
30
+
31
+ def _authenticate_ws_token(token: Optional[str]) -> Optional[str]:
32
+ """
33
+ Authenticate a JWT token passed during WebSocket auth handshake.
34
+ Returns user_id string if valid, None otherwise.
35
+ """
36
+ client = get_auth_supabase() or get_supabase()
37
+
38
+ # Dev mode — no Supabase configured
39
+ if client is None:
40
+ return DEV_USER_ID
41
+
42
+ if not token:
43
+ return None
44
+
45
+ # 1. Stateless custom JWT
46
+ try:
47
+ from src.auth.jwt_utils import decode_access_token
48
+ payload = decode_access_token(token)
49
+ user_id = payload.get("sub")
50
+ if user_id:
51
+ return str(user_id)
52
+ except Exception:
53
+ pass
54
+
55
+ # 2. Supabase JWT secret verification
56
+ if settings.supabase_jwt_secret:
57
+ try:
58
+ payload = pyjwt.decode(
59
+ token,
60
+ settings.supabase_jwt_secret,
61
+ algorithms=["HS256", "HS384", "HS512"],
62
+ options={"verify_aud": False},
63
+ )
64
+ user_id = payload.get("sub")
65
+ if user_id:
66
+ return str(user_id)
67
+ except Exception:
68
+ pass
69
+
70
+ # 3. Cached Supabase user lookup
71
+ try:
72
+ user = _verify_token_cached(client, token)
73
+ if user and getattr(user, "id", None):
74
+ return str(user.id)
75
+ except Exception as e:
76
+ logger.debug(f"WS token validation error: {e}")
77
+
78
+ return None
79
+
80
+
81
+ @ws_router.websocket("/ws/chat")
82
+ async def websocket_chat(websocket: WebSocket):
83
+ """
84
+ WebSocket endpoint for real-time streaming LLM chat responses.
85
+
86
+ Handshake Protocol:
87
+ 1. Client connects to ws(s)://<host>/ws/chat (No tokens in URL).
88
+ 2. Client MUST send an initial JSON message: {"type": "auth", "token": "<JWT>"}
89
+ 3. Server verifies token. Sends {"type": "auth_ok"} on success or closes with 1008 on failure.
90
+ 4. Client sends chat JSON messages: {"query": "...", "material_id": "...", "session_id": "...", "source_type": "..."}
91
+ 5. Server streams token strings one by one, followed by "[DONE]".
92
+ """
93
+ await websocket.accept()
94
+
95
+ # Step 1: Two-step Auth Handshake
96
+ try:
97
+ raw_msg = await asyncio.wait_for(websocket.receive_text(), timeout=10.0)
98
+ auth_data = json.loads(raw_msg)
99
+ except Exception as e:
100
+ logger.warning(f"WS auth handshake timeout or invalid message: {e}")
101
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Auth timeout or invalid payload")
102
+ return
103
+
104
+ if not isinstance(auth_data, dict) or auth_data.get("type") != "auth":
105
+ logger.warning("WS auth handshake failed: first message was not of type 'auth'")
106
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="First message must be type 'auth'")
107
+ return
108
+
109
+ token = auth_data.get("token")
110
+ user_id = _authenticate_ws_token(token)
111
+
112
+ if not user_id:
113
+ logger.warning("WS auth handshake failed: invalid or expired token")
114
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Invalid authentication token")
115
+ return
116
+
117
+ # Send auth confirmation
118
+ await websocket.send_json({"type": "auth_ok"})
119
+ logger.info(f"WebSocket client authenticated successfully for user_id={user_id}")
120
+
121
+ # Step 2: Message Loop
122
+ while True:
123
+ try:
124
+ msg_text = await websocket.receive_text()
125
+ except WebSocketDisconnect:
126
+ logger.info(f"WebSocket client disconnected gracefully (user: {user_id}).")
127
+ break
128
+ except Exception as e:
129
+ logger.error(f"WebSocket connection error: {e}")
130
+ break
131
+
132
+ try:
133
+ data = json.loads(msg_text)
134
+ except json.JSONDecodeError:
135
+ await websocket.send_json({"type": "error", "message": "Invalid JSON payload"})
136
+ continue
137
+
138
+ query = data.get("query", "").strip()
139
+ if not query:
140
+ await websocket.send_json({"type": "error", "message": "Query cannot be empty"})
141
+ continue
142
+
143
+ material_id = data.get("material_id")
144
+ session_id = data.get("session_id")
145
+ source_type = data.get("source_type", "topic")
146
+
147
+ # Material ownership check for pdf/url sources
148
+ if source_type in ("pdf", "url") and material_id:
149
+ mat = get_material(material_id)
150
+ if not mat:
151
+ await websocket.send_json({"type": "error", "message": f"No {source_type} material found."})
152
+ continue
153
+ if mat.get("user_id") != user_id:
154
+ await websocket.send_json({"type": "error", "message": "Access denied"})
155
+ continue
156
+
157
+ # Load / seed memory
158
+ mem_key = session_id or material_id
159
+ seed_msgs = None
160
+ if session_id:
161
+ try:
162
+ seed_msgs = get_session_messages(session_id)
163
+ except Exception:
164
+ seed_msgs = None
165
+
166
+ memory, memory_id = get_or_create_memory(mem_key, seed_messages=seed_msgs)
167
+
168
+ # Persist user message to session DB immediately
169
+ if session_id:
170
+ try:
171
+ append_session_message(session_id, "user", query)
172
+ except Exception as e:
173
+ logger.warning(f"Failed to append user session message: {e}")
174
+
175
+ # Summary fallback
176
+ summary_text = ""
177
+ if material_id:
178
+ mat_summary = get_summary(material_id)
179
+ summary_text = mat_summary.get("summary", "") if mat_summary else ""
180
+
181
+ # Stream response
182
+ full_answer_parts = []
183
+ try:
184
+ async for token_chunk in rag_answer_stream(
185
+ query=query,
186
+ material_id=material_id,
187
+ summaries=summary_text,
188
+ memory=memory,
189
+ ):
190
+ full_answer_parts.append(token_chunk)
191
+ await websocket.send_text(token_chunk)
192
+
193
+ # Send special completion signal
194
+ await websocket.send_text("[DONE]")
195
+
196
+ full_answer = "".join(full_answer_parts)
197
+ cleaned_answer = clean_summary(full_answer)
198
+
199
+ is_refusal = any(cleaned_answer.strip().startswith(p) for p in REFUSAL_PREFIXES)
200
+
201
+ # Persist assistant response if not a refusal
202
+ if session_id and not is_refusal:
203
+ try:
204
+ append_session_message(session_id, "assistant", cleaned_answer)
205
+ except Exception as e:
206
+ logger.warning(f"Failed to append assistant session message: {e}")
207
+
208
+ if session_id and not is_refusal:
209
+ append_memory_message(memory_id, "user", query)
210
+ append_memory_message(memory_id, "assistant", cleaned_answer)
211
+
212
+ except Exception as e:
213
+ logger.error(f"Error during WS chat streaming for user {user_id}: {e}", exc_info=True)
214
+ await websocket.send_json({"type": "error", "message": f"Error generating answer: {str(e)}"})
215
+
216
 
217
 
218
  @router.post("/ask", response_model=TutorResponse)