Daniel McKnight NeonClary commited on
Commit
0c40eb4
·
unverified ·
1 Parent(s): a94ba3b

Stream LLM Responses (#18)

Browse files

* feat: add SSE streaming endpoint for faster response display

Add /chat-stream SSE endpoint that sends advisor responses incrementally as they complete. Rewrite ChatPage handleSendMessage to consume SSE events. Add synthesize_responses method to orchestrator for aggregate mode support.

Made-with: Cursor

* Clean up changes to reduce diff and clean up unused imports

* Replace removed comments for code clarity

* Remove vestigial synthesized response handling

* Remove invalid kwarg

* Implement Cursor-generated fix to parallel response generation

* Undo `json` import name override

* Clean up unused params

* Refactor repeated message handling to match existing implementation

* Refactor to return JSON objects instead of specifically-formatted plaintext

* Remove trailing newline to reduce PR diff

* Update `chat_sequential_enhanced` docstring to be consistent with streaming response docstring

* Address review comments

* Remove unused `active_advisors` handling to simplify advisor selection

---------

Co-authored-by: NeonClary <askclary@gmail.com>

multi_llm_chatbot_backend/app/api/routes/chat.py CHANGED
@@ -1,15 +1,20 @@
1
- from fastapi import APIRouter, Request, HTTPException, Body, Depends
2
- from app.models.persona import Persona
3
- from app.core.session_manager import get_session_manager
 
 
 
 
 
 
 
 
4
  from app.api.utils import get_or_create_session_for_request_async
5
- from app.core.bootstrap import chat_orchestrator
6
  from app.core.auth import get_current_active_user
7
- from app.models.user import User
8
- from pydantic import BaseModel
9
- from typing import Optional
10
- import logging
11
  from app.core.database import get_database
12
- from bson import ObjectId
 
13
 
14
  logger = logging.getLogger(__name__)
15
 
@@ -25,6 +30,7 @@ class ChatMessage(BaseModel):
25
  session_id: Optional[str] = None
26
  chat_session_id: Optional[str] = None # MongoDB chat session ID
27
  response_length: str = "medium"
 
28
 
29
  class ReplyToAdvisor(BaseModel):
30
  user_input: str
@@ -42,6 +48,140 @@ class SwitchChatRequest(BaseModel):
42
  class NewChatRequest(BaseModel):
43
  title: Optional[str] = "New Chat"
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  @router.post("/switch-chat")
46
  async def switch_to_chat(
47
  request: SwitchChatRequest,
@@ -157,9 +297,13 @@ async def chat_sequential_enhanced(
157
  message: ChatMessage,
158
  request: Request,
159
  current_user: User = Depends(get_current_active_user)
160
- ):
161
  """
162
  Enhanced sequential chat with proper session management, document access, and intelligent persona ordering
 
 
 
 
163
  """
164
  try:
165
  # Ensure consistent session ID for document retrieval
@@ -205,7 +349,7 @@ async def chat_sequential_enhanced(
205
  session.append_message("user", message.user_input)
206
 
207
  # Check if the user's message is vague and needs clarification
208
- if chat_orchestrator._needs_clarification(session, message.user_input):
209
  clarification = await chat_orchestrator.generate_contextual_clarification(
210
  message.user_input
211
  )
 
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import traceback
5
+ from typing import Any, Dict, List, Literal, Optional
6
+
7
+ from bson import ObjectId
8
+ from fastapi import APIRouter, Depends, HTTPException, Request
9
+ from fastapi.responses import StreamingResponse
10
+ from pydantic import BaseModel, Field
11
+
12
  from app.api.utils import get_or_create_session_for_request_async
 
13
  from app.core.auth import get_current_active_user
14
+ from app.core.bootstrap import chat_orchestrator
 
 
 
15
  from app.core.database import get_database
16
+ from app.core.session_manager import get_session_manager
17
+ from app.models.user import User
18
 
19
  logger = logging.getLogger(__name__)
20
 
 
30
  session_id: Optional[str] = None
31
  chat_session_id: Optional[str] = None # MongoDB chat session ID
32
  response_length: str = "medium"
33
+ active_advisors: Optional[List[str]] = None
34
 
35
  class ReplyToAdvisor(BaseModel):
36
  user_input: str
 
48
  class NewChatRequest(BaseModel):
49
  title: Optional[str] = "New Chat"
50
 
51
+ ChatStreamEventType = Literal["error", "progress", "clarification", "advisor"]
52
+
53
+
54
+ class ChatStreamLine(BaseModel):
55
+ """One NDJSON line from ``/chat-stream``."""
56
+
57
+ type: ChatStreamEventType
58
+ data: Dict[str, Any] = Field(default_factory=dict)
59
+
60
+ def to_ndjson(self) -> str:
61
+ return json.dumps(self.model_dump(mode="json"), ensure_ascii=False) + "\n"
62
+
63
+
64
+ @router.post("/chat-stream")
65
+ async def chat_stream(
66
+ message: ChatMessage,
67
+ request: Request,
68
+ current_user: User = Depends(get_current_active_user),
69
+ ) -> StreamingResponse:
70
+ """
71
+ Streaming variant of chat-sequential (newline-delimited JSON).
72
+ @param message: ChatMessage containing user input and optional session/chat IDs
73
+ @param request: FastAPI Request object for session management
74
+ @param current_user: Authenticated user from dependency injection
75
+ @return: StreamingResponse that yields ChatStreamLine events as NDJSON
76
+ """
77
+
78
+ async def _event_generator():
79
+ try:
80
+ if message.chat_session_id:
81
+ sid = f"chat_{message.chat_session_id}"
82
+ if sid not in session_manager.sessions:
83
+ sid = await get_or_create_session_for_request_async(
84
+ request,
85
+ chat_session_id=message.chat_session_id,
86
+ user_id=str(current_user.id),
87
+ )
88
+ else:
89
+ sid = await get_or_create_session_for_request_async(request)
90
+
91
+ session = session_manager.get_session(sid)
92
+
93
+ if (
94
+ len(session.messages) > 0 and
95
+ session.messages[-1].get("role") == "user" and
96
+ session.messages[-1].get("content") == message.user_input
97
+ ):
98
+ # TODO: This should be handled in the front-end input
99
+ logger.warning(f"Repeated user input: {message.user_input}")
100
+
101
+ session.append_message("user", message.user_input)
102
+
103
+ if chat_orchestrator.needs_clarification(session, message.user_input):
104
+ clar = await chat_orchestrator.generate_contextual_clarification(message.user_input)
105
+ yield ChatStreamLine(
106
+ type="clarification",
107
+ data={
108
+ "message": clar["question"],
109
+ "suggestions": clar["suggestions"],
110
+ },
111
+ ).to_ndjson()
112
+ yield ChatStreamLine(
113
+ type="progress",
114
+ data={"phase": "complete"},
115
+ ).to_ndjson()
116
+ return
117
+
118
+ # Get personas most relevant to the current session
119
+ top_personas = await chat_orchestrator.get_top_personas(
120
+ session_id=sid,
121
+ )
122
+
123
+ done_queue: asyncio.Queue = asyncio.Queue()
124
+
125
+ async def _run(pid: str) -> None:
126
+ try:
127
+ persona = chat_orchestrator.get_persona(pid)
128
+ result = await chat_orchestrator.generate_single_persona_response(
129
+ session, persona,
130
+ message.response_length or "medium",
131
+ )
132
+ session.append_message(pid, result["response"])
133
+ await done_queue.put(result)
134
+ except Exception as e:
135
+ logger.exception(f"chat-stream _run failed for {pid}: {e}")
136
+ await done_queue.put({
137
+ "persona_id": persona.id,
138
+ "persona_name": persona.name,
139
+ "response": f"I ran into a technical issue. Please try again. ({e!s})",
140
+ "used_documents": False,
141
+ "document_chunks_used": 0,
142
+ })
143
+
144
+ tasks = [asyncio.create_task(_run(pid)) for pid in top_personas]
145
+
146
+ for _ in range(len(tasks)):
147
+ result = await done_queue.get()
148
+ line = ChatStreamLine(
149
+ type="advisor",
150
+ data={
151
+ "persona_id": result["persona_id"],
152
+ "persona_name": result["persona_name"],
153
+ "content": result["response"],
154
+ "used_documents": result.get("used_documents", False),
155
+ "document_chunks_used": result.get("document_chunks_used", 0),
156
+ },
157
+ )
158
+ yield line.to_ndjson()
159
+
160
+ await asyncio.gather(*tasks, return_exceptions=True)
161
+
162
+ yield ChatStreamLine(
163
+ type="progress",
164
+ data={"phase": "complete"},
165
+ ).to_ndjson()
166
+
167
+ except Exception as exc:
168
+ logger.error(f"chat-stream error: {exc}")
169
+ logger.error(traceback.format_exc())
170
+ yield ChatStreamLine(
171
+ type="error",
172
+ data={"detail": str(exc)},
173
+ ).to_ndjson()
174
+
175
+ return StreamingResponse(
176
+ _event_generator(),
177
+ media_type="application/x-ndjson",
178
+ headers={
179
+ "Cache-Control": "no-cache",
180
+ "X-Accel-Buffering": "no",
181
+ },
182
+ )
183
+
184
+
185
  @router.post("/switch-chat")
186
  async def switch_to_chat(
187
  request: SwitchChatRequest,
 
297
  message: ChatMessage,
298
  request: Request,
299
  current_user: User = Depends(get_current_active_user)
300
+ ) -> Dict[str, Any]:
301
  """
302
  Enhanced sequential chat with proper session management, document access, and intelligent persona ordering
303
+ @param message: ChatMessage containing user input and optional session/chat IDs
304
+ @param request: FastAPI Request object for session management
305
+ @param current_user: Authenticated user from dependency injection
306
+ @return: Dict response with LLM responses if successful, else error details
307
  """
308
  try:
309
  # Ensure consistent session ID for document retrieval
 
349
  session.append_message("user", message.user_input)
350
 
351
  # Check if the user's message is vague and needs clarification
352
+ if chat_orchestrator.needs_clarification(session, message.user_input):
353
  clarification = await chat_orchestrator.generate_contextual_clarification(
354
  message.user_input
355
  )
multi_llm_chatbot_backend/app/core/improved_orchestrator.py CHANGED
@@ -49,7 +49,7 @@ class ImprovedChatOrchestrator:
49
  session.append_message("user", user_input)
50
 
51
  # Determine if we need clarification
52
- needs_clarification = self._needs_clarification(session, user_input)
53
 
54
  if needs_clarification:
55
  # Generate clarification question
@@ -64,7 +64,7 @@ class ImprovedChatOrchestrator:
64
  }
65
 
66
  # Generate responses from all personas
67
- responses = await self._generate_persona_responses(session, response_length)
68
 
69
  return {
70
  "status": "success",
@@ -100,7 +100,7 @@ class ImprovedChatOrchestrator:
100
  available_documents = [doc["filename"] for doc in doc_stats.get("documents", [])]
101
 
102
  # Generate enhanced persona responses
103
- responses = await self._generate_persona_responses(session, response_length)
104
 
105
  return {
106
  "status": "success",
@@ -136,7 +136,7 @@ class ImprovedChatOrchestrator:
136
 
137
  return references[:3] # Limit to first 3 references
138
 
139
- def _needs_clarification(self, session: ConversationContext, user_input: str) -> bool:
140
  """
141
  Determine if the user input needs clarification.
142
  Patterns and keywords are driven by config.yaml → orchestrator section.
@@ -253,7 +253,7 @@ class ImprovedChatOrchestrator:
253
  "suggestions": fallback_suggestions,
254
  }
255
 
256
- async def _generate_persona_responses(self, session: ConversationContext, response_length: str = "medium"):
257
  """
258
  Generate responses from all personas with enhanced RAG integration
259
  """
@@ -263,7 +263,7 @@ class ImprovedChatOrchestrator:
263
  logger.info(f"Generating response for {persona_id} with enhanced RAG")
264
 
265
  # Generate persona response with enhanced RAG
266
- response_data = await self._generate_single_persona_response(session, persona, response_length)
267
 
268
  # Add persona response to session context
269
  session.append_message(persona_id, response_data["response"])
@@ -272,7 +272,7 @@ class ImprovedChatOrchestrator:
272
 
273
  return responses
274
 
275
- async def _generate_single_persona_response(self, session, persona, response_length: str = "medium"):
276
  """
277
  Enhanced version - Generate response from a single persona with enhanced RAG integration
278
  """
@@ -700,7 +700,7 @@ When analyzing the document context:
700
  logger.info(f"Generating response for {persona_id} with session {session_id}")
701
 
702
  # Generate response from single persona using consistent session ID
703
- response_data = await self._generate_single_persona_response(session, persona, response_length)
704
 
705
  # Add response to session
706
  session.append_message(persona_id, response_data["response"])
@@ -771,6 +771,9 @@ When analyzing the document context:
771
  for p in self.personas.values()
772
  ])
773
 
 
 
 
774
  app_title = get_settings().app.title
775
 
776
  prompt = f"""
 
49
  session.append_message("user", user_input)
50
 
51
  # Determine if we need clarification
52
+ needs_clarification = self.needs_clarification(session, user_input)
53
 
54
  if needs_clarification:
55
  # Generate clarification question
 
64
  }
65
 
66
  # Generate responses from all personas
67
+ responses = await self.generate_persona_responses(session, response_length)
68
 
69
  return {
70
  "status": "success",
 
100
  available_documents = [doc["filename"] for doc in doc_stats.get("documents", [])]
101
 
102
  # Generate enhanced persona responses
103
+ responses = await self.generate_persona_responses(session, response_length)
104
 
105
  return {
106
  "status": "success",
 
136
 
137
  return references[:3] # Limit to first 3 references
138
 
139
+ def needs_clarification(self, session: ConversationContext, user_input: str) -> bool:
140
  """
141
  Determine if the user input needs clarification.
142
  Patterns and keywords are driven by config.yaml → orchestrator section.
 
253
  "suggestions": fallback_suggestions,
254
  }
255
 
256
+ async def generate_persona_responses(self, session: ConversationContext, response_length: str = "medium"):
257
  """
258
  Generate responses from all personas with enhanced RAG integration
259
  """
 
263
  logger.info(f"Generating response for {persona_id} with enhanced RAG")
264
 
265
  # Generate persona response with enhanced RAG
266
+ response_data = await self.generate_single_persona_response(session, persona, response_length)
267
 
268
  # Add persona response to session context
269
  session.append_message(persona_id, response_data["response"])
 
272
 
273
  return responses
274
 
275
+ async def generate_single_persona_response(self, session, persona, response_length: str = "medium"):
276
  """
277
  Enhanced version - Generate response from a single persona with enhanced RAG integration
278
  """
 
700
  logger.info(f"Generating response for {persona_id} with session {session_id}")
701
 
702
  # Generate response from single persona using consistent session ID
703
+ response_data = await self.generate_single_persona_response(session, persona, response_length)
704
 
705
  # Add response to session
706
  session.append_message(persona_id, response_data["response"])
 
771
  for p in self.personas.values()
772
  ])
773
 
774
+ # Ensure k does not exceed the number of available personas
775
+ k = min(k, len(self.personas))
776
+
777
  app_title = get_settings().app.title
778
 
779
  prompt = f"""
phd-advisor-frontend/src/pages/ChatPage.js CHANGED
@@ -396,17 +396,12 @@ const handleNewChat = async (sessionId = null) => {
396
  setThinkingAdvisors(['system']);
397
 
398
  try {
399
- const headers = {
400
- 'Content-Type': 'application/json',
401
- 'Authorization': `Bearer ${authToken}`,
402
- };
403
-
404
-
405
- console.log('Sending message with session ID:', currentSessionId); // Debug log
406
-
407
- const response = await fetch(`${process.env.REACT_APP_API_URL}/chat-sequential`, {
408
  method: 'POST',
409
- headers: headers,
 
 
 
410
  body: JSON.stringify({
411
  user_input: inputMessage,
412
  response_length: 'medium',
@@ -418,32 +413,69 @@ const handleNewChat = async (sessionId = null) => {
418
  throw new Error(`HTTP error! status: ${response.status}`);
419
  }
420
 
421
- // Rest of the function remains the same...
422
- const data = await response.json();
423
-
424
- // Process responses...
425
- if (data.responses && Array.isArray(data.responses)) {
426
- const newResponses = data.responses.map(response => ({
427
- id: generateMessageId(),
428
- type: 'advisor',
429
- persona_id: response.persona_id,
430
- content: response.content,
431
- timestamp: new Date(),
432
- advisorName: response.persona_name || response.persona_id,
433
- used_documents: response.used_documents || false,
434
- document_chunks_used: response.document_chunks_used || 0
435
- }));
436
-
437
- setMessages(prev => [...prev, ...newResponses]);
438
-
439
- // Save advisor responses to database
440
- for (const response of newResponses) {
441
- await saveMessageToSession(response);
442
- }
443
-
444
- // Log session debug info if available
445
- if (data.session_debug) {
446
- console.log('Session debug info:', data.session_debug);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  }
448
  }
449
 
 
396
  setThinkingAdvisors(['system']);
397
 
398
  try {
399
+ const response = await fetch(`${process.env.REACT_APP_API_URL}/chat-stream`, {
 
 
 
 
 
 
 
 
400
  method: 'POST',
401
+ headers: {
402
+ 'Content-Type': 'application/json',
403
+ 'Authorization': `Bearer ${authToken}`,
404
+ },
405
  body: JSON.stringify({
406
  user_input: inputMessage,
407
  response_length: 'medium',
 
413
  throw new Error(`HTTP error! status: ${response.status}`);
414
  }
415
 
416
+ const reader = response.body.getReader();
417
+ const decoder = new TextDecoder();
418
+ let buffer = '';
419
+
420
+ while (true) {
421
+ const { done, value } = await reader.read();
422
+ if (done) break;
423
+
424
+ buffer += decoder.decode(value, { stream: true });
425
+ const lines = buffer.split('\n');
426
+ buffer = lines.pop() ?? '';
427
+
428
+ for (const line of lines) {
429
+ if (!line.trim()) continue;
430
+ const payload = JSON.parse(line);
431
+
432
+ const d = payload.data || {};
433
+
434
+ switch (payload.type) {
435
+ case 'advisor': {
436
+ const msg = {
437
+ id: generateMessageId(),
438
+ type: 'advisor',
439
+ persona_id: d.persona_id,
440
+ content: d.content,
441
+ timestamp: new Date(),
442
+ advisorName: d.persona_name || d.persona_id,
443
+ used_documents: d.used_documents || false,
444
+ document_chunks_used: d.document_chunks_used || 0,
445
+ };
446
+ setMessages(prev => [...prev, msg]);
447
+ setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id));
448
+ await saveMessageToSession(msg);
449
+ break;
450
+ }
451
+ case 'clarification':
452
+ setMessages(prev => [...prev, {
453
+ id: generateMessageId(),
454
+ type: 'clarification',
455
+ content: d.message,
456
+ suggestions: d.suggestions || [],
457
+ timestamp: new Date(),
458
+ }]);
459
+ break;
460
+ case 'progress':
461
+ if (d.phase === 'complete') {
462
+ break;
463
+ }
464
+ if (d.persona_id != null) {
465
+ setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id));
466
+ }
467
+ break;
468
+ case 'error':
469
+ setMessages(prev => [...prev, {
470
+ id: generateMessageId(),
471
+ type: 'error',
472
+ content: d.detail || 'An error occurred',
473
+ timestamp: new Date(),
474
+ }]);
475
+ break;
476
+ default:
477
+ break;
478
+ }
479
  }
480
  }
481