Spaces:
Running
Running
feat: implement WebSocket-based streaming chat
Browse files- main.py +2 -1
- rag/rag.py +135 -0
- 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.
|
|
|
|
|
|
|
| 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)
|