From 3f1f2875b49211c6fd0e3c040e90ae849198e0ff Mon Sep 17 00:00:00 2001 From: "Neon:ryan" Date: Tue, 5 May 2026 09:35:16 -0600 Subject: [PATCH 01/15] Enhance chat functionality with response mode toggle - Added a response mode toggle in EnhancedChatInput to switch between 'panel' and 'aggregated' response modes. - Updated ChatPage to manage response mode state and handle message synthesis in aggregated mode. - Enhanced advisor avatar handling in MessageBubble for improved visual representation. - Updated AppConfigContext to include a synthetic persona for aggregated responses. This update improves user experience by allowing for a more cohesive response from advisors. --- .../src/components/EnhancedChatInput.js | 61 +++------ .../src/components/MessageBubble.js | 17 ++- .../src/contexts/AppConfigContext.js | 16 ++- phd-advisor-frontend/src/pages/ChatPage.js | 117 +++++++++++++++--- 4 files changed, 146 insertions(+), 65 deletions(-) diff --git a/phd-advisor-frontend/src/components/EnhancedChatInput.js b/phd-advisor-frontend/src/components/EnhancedChatInput.js index f3d7df06..74005ceb 100644 --- a/phd-advisor-frontend/src/components/EnhancedChatInput.js +++ b/phd-advisor-frontend/src/components/EnhancedChatInput.js @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { Send, Paperclip, FileText, X, Trash2, Download, Mic, MicOff, MessageCircle, ClipboardList, Loader2, Columns3, FileOutput } from 'lucide-react'; +import { Send, Paperclip, FileText, X, Trash2, Download, Mic, MicOff, MessageCircle, ClipboardList, Loader2, Users, Sparkles } from 'lucide-react'; import FileUpload from './FileUpload'; const EnhancedChatInput = ({ @@ -13,8 +13,8 @@ const EnhancedChatInput = ({ showProfileButtons = false, onOpenOnboarding, onOpenProfileForm, - synthesizedMode = false, - onToggleSynthesized, + responseMode = 'panel', + onResponseModeChange, ensureSessionId, }) => { const [inputMessage, setInputMessage] = useState(''); @@ -318,50 +318,23 @@ const EnhancedChatInput = ({ )} + + - {/* Right - Mode Toggle + Mic + Send */} + {/* Right - Mic + Send */}
- {onToggleSynthesized && ( -
- -
- -
- )}
- id !== 'aggregated') + )} thinkingAdvisors={thinkingAdvisors} getAdvisorColors={getAdvisorColors} isDark={isDark} />
- {/* Add session title display */} - {currentSessionTitle && ( -
- {currentSessionTitle} -
- )} - {/* Export Button */} {
)} - Date: Sat, 16 May 2026 10:41:20 -0600 Subject: [PATCH 02/15] Implement response mode persistence and on-demand synthesis in ChatPage - Enhanced ChatPage to persist user-selected response mode ('panel' or 'aggregated') across sessions using localStorage. - Introduced functionality for on-demand synthesis of advisor responses, allowing users to toggle between individual and aggregated views for specific exchanges. - Added error handling for streaming chat responses and improved state management for group views and synthesizing groups. This update enhances user experience by providing a more flexible and cohesive chat interaction. --- phd-advisor-frontend/src/pages/ChatPage.js | 313 +++++++++++++++------ 1 file changed, 225 insertions(+), 88 deletions(-) diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index 750b7e00..6bd731f0 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -44,9 +44,23 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig const [isLoadingSession, setIsLoadingSession] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); - const [responseMode, setResponseMode] = useState('panel'); // 'panel' | 'aggregated' + // 'panel' | 'aggregated' — persisted so the choice survives reloads. + const [responseMode, setResponseMode] = useState(() => { + try { return localStorage.getItem('responseMode') === 'aggregated' ? 'aggregated' : 'panel'; } + catch { return 'panel'; } + }); + useEffect(() => { + try { localStorage.setItem('responseMode', responseMode); } catch { /* non-fatal */ } + }, [responseMode]); + + // Per-exchange view override: { [groupId]: 'panel' | 'aggregated' }. + // Lets the user flip an individual exchange between the advisor panel and + // the single combined answer, independently of the global default. + const [groupViews, setGroupViews] = useState({}); + // Group ids currently running an on-demand synthesis pass (for lag UX). + const [synthesizingGroups, setSynthesizingGroups] = useState({}); + - const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -363,6 +377,110 @@ const handleNewChat = async (sessionId = null) => { }; + // Reusable streaming call to /chat-stream. Lifted to component scope so the + // on-demand "generalize" action can reuse it too. + const streamChat = async (userInput, sessionId = null) => { + const response = await fetch(`${process.env.REACT_APP_API_URL}/chat-stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ + user_input: userInput, + response_length: 'medium', + chat_session_id: sessionId || currentSessionId, + }), + }); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return response; + }; + + // Ask the model to merge a set of panel responses into one cohesive answer. + // Returns the merged message object, or null if synthesis produced nothing. + // NOTE: this reuses /chat-stream client-side until a backend orchestrator + // endpoint exists; see the response-modes issue for the planned contract. + const synthesizeAggregated = async ({ userPrompt, panelMessages, groupId }) => { + const perspectives = panelMessages + .map((m, i) => `Perspective ${i + 1} — ${m.advisorName}:\n${m.content}`) + .join('\n\n'); + const synthesisPrompt = + `The user originally asked: "${userPrompt}"\n\n` + + `You received these ${panelMessages.length} expert perspectives:\n\n${perspectives}\n\n` + + `Synthesize them into a single, cohesive best-answer response that integrates the strongest points from each. ` + + `Do not list the perspectives separately — produce one unified answer addressed to the user.`; + + const synthResponse = await streamChat(synthesisPrompt); + const sReader = synthResponse.body.getReader(); + const sDecoder = new TextDecoder(); + let sBuffer = ''; + let mergedMsg = null; + + while (!mergedMsg) { + const { done, value } = await sReader.read(); + if (done) break; + sBuffer += sDecoder.decode(value, { stream: true }); + const sLines = sBuffer.split('\n'); + sBuffer = sLines.pop() ?? ''; + for (const line of sLines) { + if (!line.trim()) continue; + const payload = JSON.parse(line); + if (payload.type === 'advisor' && payload.data?.content) { + mergedMsg = { + id: generateMessageId(), + type: 'advisor', + persona_id: 'aggregated', + content: payload.data.content, + timestamp: new Date(), + advisorName: 'Partner', + is_aggregated: true, + groupId, + source_personas: panelMessages.map(r => r.persona_id), + }; + break; + } + } + } + // Drain remaining stream so the connection closes cleanly. + try { await sReader.cancel(); } catch (_) {} + return mergedMsg; + }; + + // Toggle a single exchange between the panel and the aggregated view. If the + // user asks for the aggregated view on an exchange that doesn't have one yet + // (e.g. a panel-mode or historical message), synthesize it on demand and + // persist it so it survives reloads. + const handleToggleGroupView = async (groupId, panelMessages, hasAggregated) => { + const current = groupViews[groupId] || (hasAggregated ? 'aggregated' : 'panel'); + const next = current === 'panel' ? 'aggregated' : 'panel'; + + if (next === 'aggregated' && !hasAggregated) { + // Find the user prompt that triggered this exchange. + const firstId = panelMessages[0]?.id; + const idx = messages.findIndex(m => m.id === firstId); + let userPrompt = ''; + for (let i = idx - 1; i >= 0; i--) { + if (messages[i].type === 'user') { userPrompt = messages[i].content; break; } + } + + setSynthesizingGroups(prev => ({ ...prev, [groupId]: true })); + try { + const mergedMsg = await synthesizeAggregated({ userPrompt, panelMessages, groupId }); + if (mergedMsg) { + setMessages(prev => [...prev, mergedMsg]); + setGroupViews(prev => ({ ...prev, [groupId]: 'aggregated' })); + } + } catch (err) { + console.error('On-demand synthesis failed:', err); + } finally { + setSynthesizingGroups(prev => { const n = { ...prev }; delete n[groupId]; return n; }); + } + return; + } + + setGroupViews(prev => ({ ...prev, [groupId]: next })); + }; + const handleSendMessage = async (inputMessage) => { if (!inputMessage.trim()) return; @@ -399,30 +517,14 @@ const handleNewChat = async (sessionId = null) => { setIsLoading(true); setThinkingAdvisors(['system']); - // In 'aggregated' mode we collect advisor responses internally and merge - // them into a single synthesized message instead of rendering each one. const aggregatedMode = responseMode === 'aggregated'; + const groupId = 'grp_' + generateMessageId(); + // Always collect this exchange's advisor responses so the panel is stored + // even when aggregated mode is the default — the user can toggle to it. const collectedAdvisorResponses = []; - const streamChat = async (userInput) => { - const response = await fetch(`${process.env.REACT_APP_API_URL}/chat-stream`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}`, - }, - body: JSON.stringify({ - user_input: userInput, - response_length: 'medium', - chat_session_id: sessionId - }), - }); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - return response; - }; - try { - const response = await streamChat(inputMessage); + const response = await streamChat(inputMessage, sessionId); const reader = response.body.getReader(); const decoder = new TextDecoder(); @@ -459,16 +561,16 @@ const handleNewChat = async (sessionId = null) => { advisorName: d.persona_name || d.persona_id, used_documents: d.used_documents || false, document_chunks_used: d.document_chunks_used || 0, + groupId, }; // Individual advisor messages are persisted by the /chat-stream backend endpoint. // NOTE: aggregated/synthesis metadata (is_aggregated, source_personas) is not yet // persisted — only the raw synthesis response is stored via the second /chat-stream call. - if (aggregatedMode) { - collectedAdvisorResponses.push(msg); - setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); - } else { + collectedAdvisorResponses.push(msg); + setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); + if (!aggregatedMode) { + // Panel mode: stream responses in live as they arrive. setMessages(prev => [...prev, msg]); - setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); } break; } @@ -509,66 +611,34 @@ const handleNewChat = async (sessionId = null) => { // Currently reuses /chat-stream which pollutes chat history with the // synthesis prompt as a fake "user" message. if (aggregatedMode && collectedAdvisorResponses.length > 0) { - setThinkingAdvisors(['system']); - const perspectives = collectedAdvisorResponses - .map((m, i) => `Perspective ${i + 1} — ${m.advisorName}:\n${m.content}`) - .join('\n\n'); - const synthesisPrompt = - `The user originally asked: "${inputMessage}"\n\n` + - `You received these ${collectedAdvisorResponses.length} expert perspectives:\n\n${perspectives}\n\n` + - `Synthesize them into a single, cohesive best-answer response that integrates the strongest points from each. ` + - `Do not list the perspectives separately — produce one unified answer addressed to the user.`; + // Persist the panel responses too (hidden by default in this mode) so + // the user can still toggle this exchange back to the full panel. + setMessages(prev => [...prev, ...collectedAdvisorResponses]); + // Then synthesize the single combined answer. + setThinkingAdvisors([]); + setSynthesizingGroups(prev => ({ ...prev, [groupId]: true })); try { - const synthResponse = await streamChat(synthesisPrompt); - const sReader = synthResponse.body.getReader(); - const sDecoder = new TextDecoder(); - let sBuffer = ''; - let mergedMsg = null; - - while (!mergedMsg) { - const { done, value } = await sReader.read(); - if (done) break; - sBuffer += sDecoder.decode(value, { stream: true }); - const sLines = sBuffer.split('\n'); - sBuffer = sLines.pop() ?? ''; - for (const line of sLines) { - if (!line.trim()) continue; - const payload = JSON.parse(line); - if (payload.type === 'advisor' && payload.data?.content) { - const d = payload.data; - mergedMsg = { - id: generateMessageId(), - type: 'advisor', - persona_id: 'aggregated', - content: d.content, - timestamp: new Date(), - advisorName: 'Partner', - is_aggregated: true, - source_personas: collectedAdvisorResponses.map(r => r.persona_id), - }; - break; - } - } - } - // Drain remaining stream so the connection closes cleanly. - try { await sReader.cancel(); } catch (_) {} - + const mergedMsg = await synthesizeAggregated({ + userPrompt: inputMessage, + panelMessages: collectedAdvisorResponses, + groupId, + }); if (mergedMsg) { setMessages(prev => [...prev, mergedMsg]); + setGroupViews(prev => ({ ...prev, [groupId]: 'aggregated' })); } else { - // Fallback: if synthesis returned nothing usable, show panel responses - // so the user isn't left empty-handed. - for (const m of collectedAdvisorResponses) { - setMessages(prev => [...prev, m]); - } + // Nothing usable — fall back to the panel view we already stored. + setGroupViews(prev => ({ ...prev, [groupId]: 'panel' })); } } catch (synthErr) { console.error('Synthesis pass failed, falling back to panel:', synthErr); - for (const m of collectedAdvisorResponses) { - setMessages(prev => [...prev, m]); - } + setGroupViews(prev => ({ ...prev, [groupId]: 'panel' })); + } finally { + setSynthesizingGroups(prev => { const n = { ...prev }; delete n[groupId]; return n; }); } + } else if (!aggregatedMode) { + setGroupViews(prev => ({ ...prev, [groupId]: 'panel' })); } } catch (error) { @@ -784,7 +854,21 @@ const handleNewChat = async (sessionId = null) => { advisorGroup.push(messages[i]); i++; } - groups.push({ type: 'advisor_group', messages: advisorGroup }); + const aggregatedMessages = advisorGroup.filter(m => m.is_aggregated); + const panelMessages = advisorGroup.filter(m => !m.is_aggregated); + // Prefer the shared groupId stamped at creation; fall back to a stable + // id derived from the message ids so historical/legacy exchanges + // (saved before groupId existed) can still be toggled. + const groupId = + advisorGroup.find(m => m.groupId)?.groupId || + `legacy_${advisorGroup.map(m => m.id).join('_')}`; + groups.push({ + type: 'advisor_group', + groupId, + messages: advisorGroup, + panelMessages, + aggregatedMessages, + }); } else { groups.push({ type: 'single', message: messages[i] }); i++; @@ -917,15 +1001,68 @@ const handleNewChat = async (sessionId = null) => {
{messageGroups.map((group) => ( - group.type === 'advisor_group' ? ( - m.id).join('-')} - messages={group.messages} - onReply={handleReplyToMessage} - onExpand={handleExpandMessage} - onClick={handleMessageClick} - /> - ) : ( + group.type === 'advisor_group' ? (() => { + const hasAggregated = group.aggregatedMessages.length > 0; + const view = groupViews[group.groupId] || (hasAggregated ? 'aggregated' : 'panel'); + const isSynth = !!synthesizingGroups[group.groupId]; + const showAggregated = view === 'aggregated' && hasAggregated; + const shown = showAggregated ? group.aggregatedMessages : group.panelMessages; + const showToggle = group.panelMessages.length > 1 || hasAggregated || isSynth; + const switchTo = (target) => { + if ((target === 'aggregated') !== showAggregated) { + handleToggleGroupView(group.groupId, group.panelMessages, hasAggregated); + } + }; + const segBtn = (active) => ({ + display: 'flex', alignItems: 'center', gap: 6, + fontSize: 12.5, padding: '5px 10px', border: 'none', + borderRadius: 6, cursor: isSynth ? 'default' : 'pointer', + fontFamily: 'inherit', + background: active ? 'var(--accent-primary, #6366f1)' : 'transparent', + color: active ? '#fff' : 'var(--text-secondary)', + }); + return ( +
+ {showToggle && ( +
+ + +
+ )} + {isSynth && ( +
+ + Combining advisor responses into one answer… +
+ )} + {shown.length > 0 && ( + m.id).join('-')} + messages={shown} + onReply={handleReplyToMessage} + onExpand={handleExpandMessage} + onClick={handleMessageClick} + /> + )} +
+ ); + })() : (
{group.message.type === 'user' && (
From 6135dd621f6f5a90ef40cc92d151bfcdf7f71a90 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Fri, 5 Jun 2026 16:06:06 -0700 Subject: [PATCH 03/15] added aggreagted response mode schema and an orchestrator synthesis method for aggregated responses. --- .../app/api/routes/chat.py | 4 + .../app/core/improved_orchestrator.py | 77 ++++++++ multi_llm_chatbot_backend/app/models/user.py | 17 ++ .../unit/test_chat_stream_persistence.py | 97 ++++++++++ .../app/tests/unit/test_response_mode.py | 167 ++++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py diff --git a/multi_llm_chatbot_backend/app/api/routes/chat.py b/multi_llm_chatbot_backend/app/api/routes/chat.py index f01eb1f9..2d149ce9 100644 --- a/multi_llm_chatbot_backend/app/api/routes/chat.py +++ b/multi_llm_chatbot_backend/app/api/routes/chat.py @@ -64,12 +64,16 @@ class UserInput(BaseModel): user_input: str chat_session_id: Optional[str] = None +ResponseMode = Literal["panel", "aggregated"] + + class ChatMessage(BaseModel): user_input: str session_id: Optional[str] = None chat_session_id: Optional[str] = None # MongoDB chat session ID response_length: str = "medium" active_advisors: Optional[List[str]] = None + response_mode: ResponseMode = "panel" class ReplyToAdvisor(BaseModel): user_input: str diff --git a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py index 774c8687..77b9cf56 100644 --- a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py +++ b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py @@ -488,6 +488,83 @@ async def generate_single_persona_response(self, session, persona, "context_quality": "error" } + async def synthesize_aggregated_response( + self, + user_input: str, + panel_results: List[Dict[str, Any]], + llm_client: LLMClient = None, + response_length: str = "medium", + ) -> Optional[Dict[str, Any]]: + """Merge multiple panel advisor responses into a single unified answer. + + Uses the orchestrator LLM (not a persona) to synthesize the strongest + points from each advisor into one cohesive response addressed to the + user. Returns ``None`` when synthesis produces nothing usable so the + caller can fall back to the panel responses. + """ + if not panel_results: + return None + + token_limits = {"short": 400, "medium": 700, "long": 1200} + max_tokens = token_limits.get(response_length, 700) + + perspectives = "\n\n".join( + f"### {r['persona_name']} ({r['persona_id']})\n{r['response']}" + for r in panel_results + ) + + system_prompt = ( + "You are a synthesis assistant. You will receive multiple expert " + "advisor perspectives on a user's question. Your job is to merge " + "them into a single, cohesive answer that integrates the strongest " + "points from each.\n\n" + "Guidelines:\n" + "- Produce ONE unified answer addressed directly to the user.\n" + "- Do NOT list or label the individual perspectives.\n" + "- Resolve contradictions by noting the trade-off briefly.\n" + "- Keep the tone warm, clear, and actionable.\n" + "- Match the depth and detail level of the original responses." + ) + + user_prompt = ( + f"The user asked:\n\"{user_input}\"\n\n" + f"The following {len(panel_results)} advisors responded:\n\n" + f"{perspectives}\n\n" + "Synthesize these into a single best-answer response." + ) + + try: + effective_llm = llm_client or self.llm_client + raw = await effective_llm.generate( + system_prompt=system_prompt, + context=[{"role": "user", "content": user_prompt}], + temperature=0.4, + max_tokens=max_tokens, + ) + + content = raw.strip() if raw else "" + if not content: + logger.warning("Synthesis LLM returned empty response") + return None + + return { + "persona_id": "aggregated", + "persona_name": "Aggregated", + "response": content, + "is_aggregated": True, + "source_personas": [r["persona_id"] for r in panel_results], + "used_documents": any(r.get("used_documents") for r in panel_results), + "document_chunks_used": sum( + r.get("document_chunks_used", 0) for r in panel_results + ), + "response_length": response_length, + "context_quality": "synthesized", + } + + except Exception as e: + logger.error(f"Aggregated synthesis failed: {e}") + return None + async def _retrieve_relevant_documents(self, user_input: str, session_id: str, persona_id: str = "") -> str: """ Enhanced document retrieval with document awareness and better attribution diff --git a/multi_llm_chatbot_backend/app/models/user.py b/multi_llm_chatbot_backend/app/models/user.py index 32ecd31f..6b3798e5 100644 --- a/multi_llm_chatbot_backend/app/models/user.py +++ b/multi_llm_chatbot_backend/app/models/user.py @@ -121,6 +121,10 @@ class PersistMessage(BaseModel): isExpansion: bool = False isExpandRequest: bool = False replyTo: Optional[ReplyToRef] = None + # Response grouping — links a user message with its panel + aggregated responses + response_group_id: Optional[str] = None + is_aggregated: Optional[bool] = None + source_personas: Optional[List[str]] = None @model_validator(mode='after') def check_type_constraints(self): @@ -140,6 +144,19 @@ def check_reply_metadata(self): raise ValueError("replyTo is required when isReply is True") return self + @model_validator(mode='after') + def check_aggregation_metadata(self): + if self.is_aggregated: + if self.type != 'advisor': + raise ValueError("is_aggregated can only be True for advisor messages") + if self.persona_id != 'aggregated': + raise ValueError("persona_id must be 'aggregated' when is_aggregated is True") + if not self.source_personas: + raise ValueError("source_personas is required when is_aggregated is True") + if self.source_personas and not self.is_aggregated: + raise ValueError("source_personas should only be set on aggregated messages") + return self + class ChatSession(BaseModel): model_config = ConfigDict( diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_chat_stream_persistence.py b/multi_llm_chatbot_backend/app/tests/unit/test_chat_stream_persistence.py index 608d04d6..cc3a4122 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/test_chat_stream_persistence.py +++ b/multi_llm_chatbot_backend/app/tests/unit/test_chat_stream_persistence.py @@ -257,3 +257,100 @@ def test_reply_without_reply_to_rejected(self): content="c", isReply=True, ) + + +# ------------------------------------------------------------------ +# PersistMessage – aggregation metadata +# ------------------------------------------------------------------ + + +class TestAggregatedPersistMessage(unittest.TestCase): + + def test_valid_aggregated_message(self): + msg = PersistMessage( + type="advisor", + persona_id="aggregated", + advisorName="Aggregated", + content="Synthesized answer.", + is_aggregated=True, + source_personas=["mentor", "methodologist", "career_advisor"], + response_group_id="grp_abc123", + ) + self.assertTrue(msg.is_aggregated) + self.assertEqual(msg.persona_id, "aggregated") + self.assertEqual(len(msg.source_personas), 3) + self.assertEqual(msg.response_group_id, "grp_abc123") + + def test_aggregated_requires_source_personas(self): + from pydantic import ValidationError + with self.assertRaises(ValidationError): + PersistMessage( + type="advisor", + persona_id="aggregated", + advisorName="Aggregated", + content="c", + is_aggregated=True, + ) + + def test_aggregated_requires_persona_id_aggregated(self): + from pydantic import ValidationError + with self.assertRaises(ValidationError): + PersistMessage( + type="advisor", + persona_id="mentor", + advisorName="Mentor", + content="c", + is_aggregated=True, + source_personas=["mentor"], + ) + + def test_aggregated_requires_advisor_type(self): + from pydantic import ValidationError + with self.assertRaises(ValidationError): + PersistMessage( + type="user", + content="c", + is_aggregated=True, + source_personas=["mentor"], + ) + + def test_source_personas_without_is_aggregated_rejected(self): + from pydantic import ValidationError + with self.assertRaises(ValidationError): + PersistMessage( + type="advisor", + persona_id="aggregated", + advisorName="Aggregated", + content="c", + source_personas=["mentor", "methodologist"], + ) + + def test_regular_advisor_unaffected_by_aggregation_fields(self): + msg = PersistMessage( + type="advisor", + persona_id="mentor", + advisorName="Mentor", + content="Regular advice.", + ) + self.assertIsNone(msg.is_aggregated) + self.assertIsNone(msg.source_personas) + self.assertIsNone(msg.response_group_id) + + def test_response_group_id_on_user_message(self): + msg = PersistMessage( + type="user", + content="What should I do?", + response_group_id="grp_abc123", + ) + self.assertEqual(msg.response_group_id, "grp_abc123") + + def test_response_group_id_on_panel_message(self): + msg = PersistMessage( + type="advisor", + persona_id="mentor", + advisorName="Mentor", + content="Panel advice.", + response_group_id="grp_abc123", + ) + self.assertEqual(msg.response_group_id, "grp_abc123") + self.assertIsNone(msg.is_aggregated) diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py b/multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py new file mode 100644 index 00000000..5ed6cc9d --- /dev/null +++ b/multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py @@ -0,0 +1,167 @@ +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from pydantic import ValidationError + + +# ------------------------------------------------------------------ +# ChatMessage – response_mode field +# ------------------------------------------------------------------ + + +class TestChatMessageResponseMode(unittest.TestCase): + + def test_defaults_to_panel(self): + from app.api.routes.chat import ChatMessage + msg = ChatMessage(user_input="hello") + self.assertEqual(msg.response_mode, "panel") + + def test_accepts_panel(self): + from app.api.routes.chat import ChatMessage + msg = ChatMessage(user_input="hello", response_mode="panel") + self.assertEqual(msg.response_mode, "panel") + + def test_accepts_aggregated(self): + from app.api.routes.chat import ChatMessage + msg = ChatMessage(user_input="hello", response_mode="aggregated") + self.assertEqual(msg.response_mode, "aggregated") + + def test_rejects_invalid_value(self): + from app.api.routes.chat import ChatMessage + with self.assertRaises(ValidationError): + ChatMessage(user_input="hello", response_mode="bogus") + + +# ------------------------------------------------------------------ +# synthesize_aggregated_response +# ------------------------------------------------------------------ + + +SAMPLE_PANEL_RESULTS = [ + { + "persona_id": "mentor", + "persona_name": "Socratic Mentor", + "response": "Consider the philosophical implications.", + "used_documents": False, + "document_chunks_used": 0, + "response_length": "medium", + "context_quality": "conversation_only", + }, + { + "persona_id": "methodologist", + "persona_name": "Methodologist", + "response": "Use a mixed-methods approach.", + "used_documents": True, + "document_chunks_used": 3, + "response_length": "medium", + "context_quality": "high", + }, +] + + +def _run(coro): + """Run an async coroutine synchronously for testing.""" + return asyncio.run(coro) + + +class TestSynthesizeAggregatedResponse(unittest.TestCase): + + def _make_orchestrator(self, llm_response="Synthesized answer."): + mock_llm = AsyncMock() + mock_llm.generate = AsyncMock(return_value=llm_response) + + from app.core.improved_orchestrator import ImprovedChatOrchestrator + orch = ImprovedChatOrchestrator.__new__(ImprovedChatOrchestrator) + orch.personas = {} + orch.llm_client = mock_llm + orch.session_manager = MagicMock() + orch.context_manager = MagicMock() + return orch, mock_llm + + def test_returns_expected_shape(self): + orch, _ = self._make_orchestrator("A unified answer.") + result = _run(orch.synthesize_aggregated_response( + user_input="What should I do?", + panel_results=SAMPLE_PANEL_RESULTS, + )) + self.assertIsNotNone(result) + self.assertEqual(result["persona_id"], "aggregated") + self.assertTrue(result["is_aggregated"]) + self.assertEqual(result["source_personas"], ["mentor", "methodologist"]) + self.assertEqual(result["response"], "A unified answer.") + self.assertEqual(result["context_quality"], "synthesized") + + def test_aggregates_document_usage(self): + orch, _ = self._make_orchestrator("Answer.") + result = _run(orch.synthesize_aggregated_response( + user_input="question", + panel_results=SAMPLE_PANEL_RESULTS, + )) + self.assertTrue(result["used_documents"]) + self.assertEqual(result["document_chunks_used"], 3) + + def test_empty_panel_results_returns_none(self): + orch, _ = self._make_orchestrator() + result = _run(orch.synthesize_aggregated_response( + user_input="question", + panel_results=[], + )) + self.assertIsNone(result) + + def test_llm_empty_response_returns_none(self): + orch, _ = self._make_orchestrator("") + result = _run(orch.synthesize_aggregated_response( + user_input="question", + panel_results=SAMPLE_PANEL_RESULTS, + )) + self.assertIsNone(result) + + def test_llm_whitespace_response_returns_none(self): + orch, _ = self._make_orchestrator(" \n ") + result = _run(orch.synthesize_aggregated_response( + user_input="question", + panel_results=SAMPLE_PANEL_RESULTS, + )) + self.assertIsNone(result) + + def test_llm_exception_returns_none(self): + orch, mock_llm = self._make_orchestrator() + mock_llm.generate = AsyncMock(side_effect=RuntimeError("API timeout")) + result = _run(orch.synthesize_aggregated_response( + user_input="question", + panel_results=SAMPLE_PANEL_RESULTS, + )) + self.assertIsNone(result) + + def test_uses_provided_llm_client(self): + orch, default_llm = self._make_orchestrator() + override_llm = AsyncMock() + override_llm.generate = AsyncMock(return_value="Override answer.") + result = _run(orch.synthesize_aggregated_response( + user_input="question", + panel_results=SAMPLE_PANEL_RESULTS, + llm_client=override_llm, + )) + override_llm.generate.assert_called_once() + default_llm.generate.assert_not_called() + self.assertEqual(result["response"], "Override answer.") + + def test_respects_response_length(self): + orch, mock_llm = self._make_orchestrator("Short.") + _run(orch.synthesize_aggregated_response( + user_input="question", + panel_results=SAMPLE_PANEL_RESULTS, + response_length="short", + )) + call_kwargs = mock_llm.generate.call_args.kwargs + self.assertEqual(call_kwargs["max_tokens"], 400) + + def test_persona_name_is_set(self): + orch, _ = self._make_orchestrator("Answer.") + result = _run(orch.synthesize_aggregated_response( + user_input="question", + panel_results=SAMPLE_PANEL_RESULTS, + )) + self.assertIn("persona_name", result) + self.assertTrue(len(result["persona_name"]) > 0) From a12d70f1441b0dd37131f48324b32ecf302f98ed Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Fri, 5 Jun 2026 16:21:52 -0700 Subject: [PATCH 04/15] wired aggregated response mode into /chat-stream endpoints. --- .../app/api/routes/chat.py | 142 ++++++++++++++---- 1 file changed, 115 insertions(+), 27 deletions(-) diff --git a/multi_llm_chatbot_backend/app/api/routes/chat.py b/multi_llm_chatbot_backend/app/api/routes/chat.py index 2d149ce9..23463302 100644 --- a/multi_llm_chatbot_backend/app/api/routes/chat.py +++ b/multi_llm_chatbot_backend/app/api/routes/chat.py @@ -103,7 +103,7 @@ class ChatStreamLine(BaseModel): def to_ndjson(self) -> str: return json.dumps(self.model_dump(mode="json"), ensure_ascii=False) + "\n" - +# TODO: Refactor this function into smaller composable helpers so it's more readable and maintainable. @router.post("/chat-stream") async def chat_stream( message: ChatMessage, @@ -140,11 +140,16 @@ async def _event_generator(): session = session_manager.get_session(sid) # Append user message to in-memory session and persist to MongoDB + response_group_id = str(ObjectId()) session.append_message("user", message.user_input) if message.chat_session_id: await persist_message( message.chat_session_id, - PersistMessage(type="user", content=message.user_input), + PersistMessage( + type="user", + content=message.user_input, + response_group_id=response_group_id, + ), ) yield ChatStreamLine( type="progress", data={"phase": "received"}, @@ -279,33 +284,116 @@ async def _run(pid: str) -> None: tasks = [asyncio.create_task(_run(pid)) for pid in top_personas] - for _ in range(len(tasks)): - result = await done_queue.get() - if message.chat_session_id: - await persist_message( - message.chat_session_id, - PersistMessage( - type="advisor", - persona_id=result["persona_id"], - advisorName=result["persona_name"], - content=result["response"], - used_documents=result.get("used_documents", False), - document_chunks_used=result.get("document_chunks_used", 0), - ), - ) - line = ChatStreamLine( - type="advisor", - data={ - "persona_id": result["persona_id"], - "persona_name": result["persona_name"], - "content": result["response"], - "used_documents": result.get("used_documents", False), - "document_chunks_used": result.get("document_chunks_used", 0), - }, + if message.response_mode == "panel": + # ---- Panel mode: yield each advisor response as it arrives ---- + for _ in range(len(tasks)): + result = await done_queue.get() + if message.chat_session_id: + await persist_message( + message.chat_session_id, + PersistMessage( + type="advisor", + persona_id=result["persona_id"], + advisorName=result["persona_name"], + content=result["response"], + used_documents=result.get("used_documents", False), + document_chunks_used=result.get("document_chunks_used", 0), + response_group_id=response_group_id, + ), + ) + yield ChatStreamLine( + type="advisor", + data={ + "persona_id": result["persona_id"], + "persona_name": result["persona_name"], + "content": result["response"], + "used_documents": result.get("used_documents", False), + "document_chunks_used": result.get("document_chunks_used", 0), + "response_group_id": response_group_id, + }, + ).to_ndjson() + + await asyncio.gather(*tasks, return_exceptions=True) + + else: + # ---- Aggregated mode: collect all, synthesize, yield one ---- + yield ChatStreamLine( + type="progress", + data={"phase": "generating"}, + ).to_ndjson() + + panel_results = [] + for _ in range(len(tasks)): + result = await done_queue.get() + panel_results.append(result) + if message.chat_session_id: + await persist_message( + message.chat_session_id, + PersistMessage( + type="advisor", + persona_id=result["persona_id"], + advisorName=result["persona_name"], + content=result["response"], + used_documents=result.get("used_documents", False), + document_chunks_used=result.get("document_chunks_used", 0), + response_group_id=response_group_id, + ), + ) + + await asyncio.gather(*tasks, return_exceptions=True) + + yield ChatStreamLine( + type="progress", + data={"phase": "synthesizing"}, + ).to_ndjson() + + synth_result = await chat_orchestrator.synthesize_aggregated_response( + user_input=message.user_input, + panel_results=panel_results, + llm_client=orchestrator_llm, + response_length=message.response_length or "medium", ) - yield line.to_ndjson() - await asyncio.gather(*tasks, return_exceptions=True) + if synth_result: + if message.chat_session_id: + await persist_message( + message.chat_session_id, + PersistMessage( + type="advisor", + persona_id="aggregated", + advisorName=synth_result["persona_name"], + content=synth_result["response"], + is_aggregated=True, + source_personas=synth_result["source_personas"], + response_group_id=response_group_id, + ), + ) + yield ChatStreamLine( + type="advisor", + data={ + "persona_id": "aggregated", + "persona_name": synth_result["persona_name"], + "content": synth_result["response"], + "is_aggregated": True, + "source_personas": synth_result["source_personas"], + "response_group_id": response_group_id, + }, + ).to_ndjson() + else: + # Synthesis failed — fall back to yielding panel responses + logger.warning("Aggregated synthesis failed, falling back to panel") + for result in panel_results: + yield ChatStreamLine( + type="advisor", + data={ + "persona_id": result["persona_id"], + "persona_name": result["persona_name"], + "content": result["response"], + "used_documents": result.get("used_documents", False), + "document_chunks_used": result.get("document_chunks_used", 0), + "response_group_id": response_group_id, + }, + ).to_ndjson() yield ChatStreamLine( type="progress", From 62976e257a72e98555b10512d70dc6f896248021 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Fri, 5 Jun 2026 16:42:57 -0700 Subject: [PATCH 05/15] wired frontend to backend aggregated response mode. --- .../src/components/SettingsModal.js | 2 +- phd-advisor-frontend/src/pages/ChatPage.js | 138 ++---------------- 2 files changed, 16 insertions(+), 124 deletions(-) diff --git a/phd-advisor-frontend/src/components/SettingsModal.js b/phd-advisor-frontend/src/components/SettingsModal.js index c8815a4f..05223d01 100644 --- a/phd-advisor-frontend/src/components/SettingsModal.js +++ b/phd-advisor-frontend/src/components/SettingsModal.js @@ -274,7 +274,7 @@ const SettingsModal = ({ }`, }); - const advisorEntries = Object.entries(advisors || {}); + const advisorEntries = Object.entries(advisors || {}).filter(([id]) => id !== 'aggregated'); const enabledCount = advisorEntries.filter(([id]) => isAdvisorEnabled(id)).length; const setAll = (enabled) => setAllAdvisorsEnabled(enabled); diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index 6bd731f0..5b4185ca 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -390,94 +390,17 @@ const handleNewChat = async (sessionId = null) => { user_input: userInput, response_length: 'medium', chat_session_id: sessionId || currentSessionId, + response_mode: responseMode, }), }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return response; }; - // Ask the model to merge a set of panel responses into one cohesive answer. - // Returns the merged message object, or null if synthesis produced nothing. - // NOTE: this reuses /chat-stream client-side until a backend orchestrator - // endpoint exists; see the response-modes issue for the planned contract. - const synthesizeAggregated = async ({ userPrompt, panelMessages, groupId }) => { - const perspectives = panelMessages - .map((m, i) => `Perspective ${i + 1} — ${m.advisorName}:\n${m.content}`) - .join('\n\n'); - const synthesisPrompt = - `The user originally asked: "${userPrompt}"\n\n` + - `You received these ${panelMessages.length} expert perspectives:\n\n${perspectives}\n\n` + - `Synthesize them into a single, cohesive best-answer response that integrates the strongest points from each. ` + - `Do not list the perspectives separately — produce one unified answer addressed to the user.`; - - const synthResponse = await streamChat(synthesisPrompt); - const sReader = synthResponse.body.getReader(); - const sDecoder = new TextDecoder(); - let sBuffer = ''; - let mergedMsg = null; - - while (!mergedMsg) { - const { done, value } = await sReader.read(); - if (done) break; - sBuffer += sDecoder.decode(value, { stream: true }); - const sLines = sBuffer.split('\n'); - sBuffer = sLines.pop() ?? ''; - for (const line of sLines) { - if (!line.trim()) continue; - const payload = JSON.parse(line); - if (payload.type === 'advisor' && payload.data?.content) { - mergedMsg = { - id: generateMessageId(), - type: 'advisor', - persona_id: 'aggregated', - content: payload.data.content, - timestamp: new Date(), - advisorName: 'Partner', - is_aggregated: true, - groupId, - source_personas: panelMessages.map(r => r.persona_id), - }; - break; - } - } - } - // Drain remaining stream so the connection closes cleanly. - try { await sReader.cancel(); } catch (_) {} - return mergedMsg; - }; - - // Toggle a single exchange between the panel and the aggregated view. If the - // user asks for the aggregated view on an exchange that doesn't have one yet - // (e.g. a panel-mode or historical message), synthesize it on demand and - // persist it so it survives reloads. - const handleToggleGroupView = async (groupId, panelMessages, hasAggregated) => { + const handleToggleGroupView = (groupId, panelMessages, hasAggregated) => { const current = groupViews[groupId] || (hasAggregated ? 'aggregated' : 'panel'); const next = current === 'panel' ? 'aggregated' : 'panel'; - - if (next === 'aggregated' && !hasAggregated) { - // Find the user prompt that triggered this exchange. - const firstId = panelMessages[0]?.id; - const idx = messages.findIndex(m => m.id === firstId); - let userPrompt = ''; - for (let i = idx - 1; i >= 0; i--) { - if (messages[i].type === 'user') { userPrompt = messages[i].content; break; } - } - - setSynthesizingGroups(prev => ({ ...prev, [groupId]: true })); - try { - const mergedMsg = await synthesizeAggregated({ userPrompt, panelMessages, groupId }); - if (mergedMsg) { - setMessages(prev => [...prev, mergedMsg]); - setGroupViews(prev => ({ ...prev, [groupId]: 'aggregated' })); - } - } catch (err) { - console.error('On-demand synthesis failed:', err); - } finally { - setSynthesizingGroups(prev => { const n = { ...prev }; delete n[groupId]; return n; }); - } - return; - } - + if (next === 'aggregated' && !hasAggregated) return; setGroupViews(prev => ({ ...prev, [groupId]: next })); }; @@ -517,7 +440,6 @@ const handleNewChat = async (sessionId = null) => { setIsLoading(true); setThinkingAdvisors(['system']); - const aggregatedMode = responseMode === 'aggregated'; const groupId = 'grp_' + generateMessageId(); // Always collect this exchange's advisor responses so the panel is stored // even when aggregated mode is the default — the user can toggle to it. @@ -562,16 +484,15 @@ const handleNewChat = async (sessionId = null) => { used_documents: d.used_documents || false, document_chunks_used: d.document_chunks_used || 0, groupId, + is_aggregated: d.is_aggregated || false, + source_personas: d.source_personas || null, }; - // Individual advisor messages are persisted by the /chat-stream backend endpoint. - // NOTE: aggregated/synthesis metadata (is_aggregated, source_personas) is not yet - // persisted — only the raw synthesis response is stored via the second /chat-stream call. + // Persistence is handled by the /chat-stream backend endpoint. + // The backend controls which events are sent: panel mode sends + // individual advisor events, aggregated mode sends one synthesized event. collectedAdvisorResponses.push(msg); setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); - if (!aggregatedMode) { - // Panel mode: stream responses in live as they arrive. - setMessages(prev => [...prev, msg]); - } + setMessages(prev => [...prev, msg]); break; } case 'clarification': @@ -587,6 +508,9 @@ const handleNewChat = async (sessionId = null) => { if (d.phase === 'complete') { break; } + if (d.phase === 'synthesizing') { + setSynthesizingGroups(prev => ({ ...prev, [groupId]: true })); + } if (d.persona_id != null) { setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); } @@ -605,41 +529,9 @@ const handleNewChat = async (sessionId = null) => { } } - // TEMPORARY: Client-side synthesis — will be replaced by a dedicated backend - // endpoint that generates & stores all 4 responses (3 panel + 1 aggregated) - // with proper schema (response_group_id, is_aggregated, source_personas). - // Currently reuses /chat-stream which pollutes chat history with the - // synthesis prompt as a fake "user" message. - if (aggregatedMode && collectedAdvisorResponses.length > 0) { - // Persist the panel responses too (hidden by default in this mode) so - // the user can still toggle this exchange back to the full panel. - setMessages(prev => [...prev, ...collectedAdvisorResponses]); - - // Then synthesize the single combined answer. - setThinkingAdvisors([]); - setSynthesizingGroups(prev => ({ ...prev, [groupId]: true })); - try { - const mergedMsg = await synthesizeAggregated({ - userPrompt: inputMessage, - panelMessages: collectedAdvisorResponses, - groupId, - }); - if (mergedMsg) { - setMessages(prev => [...prev, mergedMsg]); - setGroupViews(prev => ({ ...prev, [groupId]: 'aggregated' })); - } else { - // Nothing usable — fall back to the panel view we already stored. - setGroupViews(prev => ({ ...prev, [groupId]: 'panel' })); - } - } catch (synthErr) { - console.error('Synthesis pass failed, falling back to panel:', synthErr); - setGroupViews(prev => ({ ...prev, [groupId]: 'panel' })); - } finally { - setSynthesizingGroups(prev => { const n = { ...prev }; delete n[groupId]; return n; }); - } - } else if (!aggregatedMode) { - setGroupViews(prev => ({ ...prev, [groupId]: 'panel' })); - } + const hasAggregated = collectedAdvisorResponses.some(m => m.is_aggregated); + setGroupViews(prev => ({ ...prev, [groupId]: hasAggregated ? 'aggregated' : 'panel' })); + setSynthesizingGroups(prev => { const n = { ...prev }; delete n[groupId]; return n; }); } catch (error) { console.error('Error sending message:', error); From 0acd9aadb1b41362ab460f716131527bf7cc78d3 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Fri, 5 Jun 2026 17:12:59 -0700 Subject: [PATCH 06/15] bug fixes to aggregate response quality, panel toggle, and brief panel message flash. --- multi_llm_chatbot_backend/app/api/routes/chat.py | 13 +++++++++++++ .../app/core/improved_orchestrator.py | 13 ++++++++----- phd-advisor-frontend/src/pages/ChatPage.js | 14 ++++++++++---- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/multi_llm_chatbot_backend/app/api/routes/chat.py b/multi_llm_chatbot_backend/app/api/routes/chat.py index 23463302..6a640fb8 100644 --- a/multi_llm_chatbot_backend/app/api/routes/chat.py +++ b/multi_llm_chatbot_backend/app/api/routes/chat.py @@ -342,6 +342,19 @@ async def _run(pid: str) -> None: await asyncio.gather(*tasks, return_exceptions=True) + for result in panel_results: + yield ChatStreamLine( + type="advisor", + data={ + "persona_id": result["persona_id"], + "persona_name": result["persona_name"], + "content": result["response"], + "used_documents": result.get("used_documents", False), + "document_chunks_used": result.get("document_chunks_used", 0), + "response_group_id": response_group_id, + }, + ).to_ndjson() + yield ChatStreamLine( type="progress", data={"phase": "synthesizing"}, diff --git a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py index 77b9cf56..6a1412d0 100644 --- a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py +++ b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py @@ -1,5 +1,5 @@ from typing import Dict, List, Optional, Any -from app.models.persona import Persona +from app.models.persona import Persona, COMPACT_MARKDOWN_V1, STRUCTURE_HINTS, _ensure_compact_shape from app.core.session_manager import ConversationContext, get_session_manager from app.core.context_manager import get_context_manager from app.core.rag_manager import get_rag_manager @@ -505,7 +505,7 @@ async def synthesize_aggregated_response( if not panel_results: return None - token_limits = {"short": 400, "medium": 700, "long": 1200} + token_limits = {"short": 800, "medium": 1500, "long": 2400} max_tokens = token_limits.get(response_length, 700) perspectives = "\n\n".join( @@ -513,6 +513,8 @@ async def synthesize_aggregated_response( for r in panel_results ) + structure_hint = STRUCTURE_HINTS.get(response_length, STRUCTURE_HINTS["medium"]) + system_prompt = ( "You are a synthesis assistant. You will receive multiple expert " "advisor perspectives on a user's question. Your job is to merge " @@ -522,8 +524,9 @@ async def synthesize_aggregated_response( "- Produce ONE unified answer addressed directly to the user.\n" "- Do NOT list or label the individual perspectives.\n" "- Resolve contradictions by noting the trade-off briefly.\n" - "- Keep the tone warm, clear, and actionable.\n" - "- Match the depth and detail level of the original responses." + "- Keep the tone warm, clear, and actionable.\n\n" + f"{COMPACT_MARKDOWN_V1}\n\n" + f"{structure_hint}" ) user_prompt = ( @@ -542,7 +545,7 @@ async def synthesize_aggregated_response( max_tokens=max_tokens, ) - content = raw.strip() if raw else "" + content = _ensure_compact_shape(raw.strip() if raw else "", response_length) if not content: logger.warning("Synthesis LLM returned empty response") return None diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index 5b4185ca..56127854 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -440,6 +440,7 @@ const handleNewChat = async (sessionId = null) => { setIsLoading(true); setThinkingAdvisors(['system']); + const aggregatedMode = responseMode === 'aggregated'; const groupId = 'grp_' + generateMessageId(); // Always collect this exchange's advisor responses so the panel is stored // even when aggregated mode is the default — the user can toggle to it. @@ -487,12 +488,11 @@ const handleNewChat = async (sessionId = null) => { is_aggregated: d.is_aggregated || false, source_personas: d.source_personas || null, }; - // Persistence is handled by the /chat-stream backend endpoint. - // The backend controls which events are sent: panel mode sends - // individual advisor events, aggregated mode sends one synthesized event. collectedAdvisorResponses.push(msg); setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); - setMessages(prev => [...prev, msg]); + if (!aggregatedMode || msg.is_aggregated) { + setMessages(prev => [...prev, msg]); + } break; } case 'clarification': @@ -530,6 +530,12 @@ const handleNewChat = async (sessionId = null) => { } const hasAggregated = collectedAdvisorResponses.some(m => m.is_aggregated); + if (aggregatedMode) { + const deferred = collectedAdvisorResponses.filter(m => !m.is_aggregated); + if (deferred.length > 0) { + setMessages(prev => [...prev, ...deferred]); + } + } setGroupViews(prev => ({ ...prev, [groupId]: hasAggregated ? 'aggregated' : 'panel' })); setSynthesizingGroups(prev => { const n = { ...prev }; delete n[groupId]; return n; }); From d1f67785eee37419802f4318f1f3a9d87063216e Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Fri, 5 Jun 2026 17:18:25 -0700 Subject: [PATCH 07/15] fix tests to match bug fixes. --- multi_llm_chatbot_backend/app/core/improved_orchestrator.py | 5 +++-- .../app/tests/unit/test_response_mode.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py index 6a1412d0..0d17ed85 100644 --- a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py +++ b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py @@ -545,10 +545,11 @@ async def synthesize_aggregated_response( max_tokens=max_tokens, ) - content = _ensure_compact_shape(raw.strip() if raw else "", response_length) - if not content: + stripped = raw.strip() if raw else "" + if not stripped: logger.warning("Synthesis LLM returned empty response") return None + content = _ensure_compact_shape(stripped, response_length) return { "persona_id": "aggregated", diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py b/multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py index 5ed6cc9d..de85239f 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py +++ b/multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py @@ -89,7 +89,7 @@ def test_returns_expected_shape(self): self.assertEqual(result["persona_id"], "aggregated") self.assertTrue(result["is_aggregated"]) self.assertEqual(result["source_personas"], ["mentor", "methodologist"]) - self.assertEqual(result["response"], "A unified answer.") + self.assertIn("### Thought", result["response"]) self.assertEqual(result["context_quality"], "synthesized") def test_aggregates_document_usage(self): @@ -145,7 +145,7 @@ def test_uses_provided_llm_client(self): )) override_llm.generate.assert_called_once() default_llm.generate.assert_not_called() - self.assertEqual(result["response"], "Override answer.") + self.assertIn("### Thought", result["response"]) def test_respects_response_length(self): orch, mock_llm = self._make_orchestrator("Short.") @@ -155,7 +155,7 @@ def test_respects_response_length(self): response_length="short", )) call_kwargs = mock_llm.generate.call_args.kwargs - self.assertEqual(call_kwargs["max_tokens"], 400) + self.assertEqual(call_kwargs["max_tokens"], 800) def test_persona_name_is_set(self): orch, _ = self._make_orchestrator("Answer.") From 8b0a5ad734010ecb051eaeffd2137c80804e62ce Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Mon, 8 Jun 2026 12:29:11 -0700 Subject: [PATCH 08/15] added /synthesize endpoint for on-demand aggregated responses if panel mode is selected first from the main chat UI. --- .../app/api/routes/chat.py | 64 +++++++++++++++++++ .../app/tests/unit/test_response_mode.py | 55 ++++++++++++++++ phd-advisor-frontend/src/pages/ChatPage.js | 60 ++++++++++++++++- 3 files changed, 177 insertions(+), 2 deletions(-) diff --git a/multi_llm_chatbot_backend/app/api/routes/chat.py b/multi_llm_chatbot_backend/app/api/routes/chat.py index 6a640fb8..5e62f5ea 100644 --- a/multi_llm_chatbot_backend/app/api/routes/chat.py +++ b/multi_llm_chatbot_backend/app/api/routes/chat.py @@ -75,6 +75,22 @@ class ChatMessage(BaseModel): active_advisors: Optional[List[str]] = None response_mode: ResponseMode = "panel" +class PanelResult(BaseModel): + persona_id: str + persona_name: str + response: str + used_documents: bool = False + document_chunks_used: int = 0 + + +class SynthesizeRequest(BaseModel): + user_input: str + panel_results: List[PanelResult] = Field(min_length=1) + chat_session_id: str + response_group_id: str + response_length: Literal["short", "medium", "long"] = "medium" + + class ReplyToAdvisor(BaseModel): user_input: str advisor_id: str @@ -431,6 +447,54 @@ async def _run(pid: str) -> None: ) +@router.post("/synthesize") +async def synthesize_aggregated( + request: SynthesizeRequest, + current_user: User = Depends(get_current_active_user), +): + """On-demand synthesis of panel advisor responses into a single aggregated answer. + + Called when a user toggles to the 'Generalized' view on a panel-mode + exchange that doesn't yet have an aggregated response. + """ + try: + llm_clients = resolve_llm_clients(current_user) + orchestrator_llm = llm_clients.get("orchestrator") + + panel_dicts = [r.model_dump() for r in request.panel_results] + + result = await chat_orchestrator.synthesize_aggregated_response( + user_input=request.user_input, + panel_results=panel_dicts, + llm_client=orchestrator_llm, + response_length=request.response_length, + ) + + if not result: + raise HTTPException(status_code=502, detail="Synthesis produced no usable response") + + await persist_message( + request.chat_session_id, + PersistMessage( + type="advisor", + persona_id="aggregated", + advisorName=result["persona_name"], + content=result["response"], + is_aggregated=True, + source_personas=result["source_personas"], + response_group_id=request.response_group_id, + ), + ) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Synthesis endpoint error: {e}") + raise HTTPException(status_code=500, detail="Synthesis failed") + + @router.post("/switch-chat") async def switch_to_chat( request: SwitchChatRequest, diff --git a/multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py b/multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py index de85239f..7c0c68a0 100644 --- a/multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py +++ b/multi_llm_chatbot_backend/app/tests/unit/test_response_mode.py @@ -165,3 +165,58 @@ def test_persona_name_is_set(self): )) self.assertIn("persona_name", result) self.assertTrue(len(result["persona_name"]) > 0) + + +# ------------------------------------------------------------------ +# SynthesizeRequest – request model validation +# ------------------------------------------------------------------ + + +VALID_SYNTHESIZE_PAYLOAD = { + "user_input": "What should I do?", + "panel_results": [ + {"persona_id": "mentor", "persona_name": "Mentor", "response": "Think deeply."}, + {"persona_id": "methodologist", "persona_name": "Methodologist", "response": "Use mixed methods."}, + ], + "chat_session_id": "abc123", + "response_group_id": "grp_456", +} + + +class TestSynthesizeRequest(unittest.TestCase): + + def test_valid_request(self): + from app.api.routes.chat import SynthesizeRequest + req = SynthesizeRequest(**VALID_SYNTHESIZE_PAYLOAD) + self.assertEqual(req.user_input, "What should I do?") + self.assertEqual(len(req.panel_results), 2) + self.assertEqual(req.response_length, "medium") + + def test_empty_panel_results_rejected(self): + from app.api.routes.chat import SynthesizeRequest + payload = {**VALID_SYNTHESIZE_PAYLOAD, "panel_results": []} + with self.assertRaises(ValidationError): + SynthesizeRequest(**payload) + + def test_missing_chat_session_id_rejected(self): + from app.api.routes.chat import SynthesizeRequest + payload = {k: v for k, v in VALID_SYNTHESIZE_PAYLOAD.items() if k != "chat_session_id"} + with self.assertRaises(ValidationError): + SynthesizeRequest(**payload) + + def test_missing_response_group_id_rejected(self): + from app.api.routes.chat import SynthesizeRequest + payload = {k: v for k, v in VALID_SYNTHESIZE_PAYLOAD.items() if k != "response_group_id"} + with self.assertRaises(ValidationError): + SynthesizeRequest(**payload) + + def test_invalid_response_length_rejected(self): + from app.api.routes.chat import SynthesizeRequest + payload = {**VALID_SYNTHESIZE_PAYLOAD, "response_length": "huge"} + with self.assertRaises(ValidationError): + SynthesizeRequest(**payload) + + def test_response_length_defaults_to_medium(self): + from app.api.routes.chat import SynthesizeRequest + req = SynthesizeRequest(**VALID_SYNTHESIZE_PAYLOAD) + self.assertEqual(req.response_length, "medium") diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index 56127854..99b42acd 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -397,10 +397,66 @@ const handleNewChat = async (sessionId = null) => { return response; }; - const handleToggleGroupView = (groupId, panelMessages, hasAggregated) => { + const handleToggleGroupView = async (groupId, panelMessages, hasAggregated) => { const current = groupViews[groupId] || (hasAggregated ? 'aggregated' : 'panel'); const next = current === 'panel' ? 'aggregated' : 'panel'; - if (next === 'aggregated' && !hasAggregated) return; + + if (next === 'aggregated' && !hasAggregated) { + const firstId = panelMessages[0]?.id; + const idx = messages.findIndex(m => m.id === firstId); + let userPrompt = ''; + for (let i = idx - 1; i >= 0; i--) { + if (messages[i].type === 'user') { userPrompt = messages[i].content; break; } + } + + setSynthesizingGroups(prev => ({ ...prev, [groupId]: true })); + try { + const resp = await fetch(`${process.env.REACT_APP_API_URL}/synthesize`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ + user_input: userPrompt, + panel_results: panelMessages.map(m => ({ + persona_id: m.persona_id, + persona_name: m.advisorName, + response: m.content, + used_documents: m.used_documents || false, + document_chunks_used: m.document_chunks_used || 0, + })), + chat_session_id: currentSessionId, + response_group_id: groupId, + }), + }); + + if (resp.ok) { + const data = await resp.json(); + const mergedMsg = { + id: generateMessageId(), + type: 'advisor', + persona_id: 'aggregated', + content: data.response, + timestamp: new Date(), + advisorName: data.persona_name, + is_aggregated: true, + groupId, + source_personas: data.source_personas, + }; + setMessages(prev => [...prev, mergedMsg]); + setGroupViews(prev => ({ ...prev, [groupId]: 'aggregated' })); + } else { + console.error('Synthesis request failed:', resp.status); + } + } catch (err) { + console.error('On-demand synthesis failed:', err); + } finally { + setSynthesizingGroups(prev => { const n = { ...prev }; delete n[groupId]; return n; }); + } + return; + } + setGroupViews(prev => ({ ...prev, [groupId]: next })); }; From 5accf72a78d874889e13f44705f3d966a3e3c8fa Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Mon, 8 Jun 2026 12:46:35 -0700 Subject: [PATCH 09/15] hid reply/expand on aggregated responses and cleaned up persona name to better match rest of UI. --- multi_llm_chatbot_backend/app/core/improved_orchestrator.py | 2 +- phd-advisor-frontend/src/components/MessageBubble.js | 4 ++++ phd-advisor-frontend/src/components/SettingsModal.js | 4 ++-- phd-advisor-frontend/src/contexts/AppConfigContext.js | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py index 0d17ed85..f8614cc3 100644 --- a/multi_llm_chatbot_backend/app/core/improved_orchestrator.py +++ b/multi_llm_chatbot_backend/app/core/improved_orchestrator.py @@ -553,7 +553,7 @@ async def synthesize_aggregated_response( return { "persona_id": "aggregated", - "persona_name": "Aggregated", + "persona_name": "Generalized", "response": content, "is_aggregated": True, "source_personas": [r["persona_id"] for r in panel_results], diff --git a/phd-advisor-frontend/src/components/MessageBubble.js b/phd-advisor-frontend/src/components/MessageBubble.js index 9d123e4d..ee194bbe 100644 --- a/phd-advisor-frontend/src/components/MessageBubble.js +++ b/phd-advisor-frontend/src/components/MessageBubble.js @@ -402,6 +402,7 @@ const MessageBubble = ({ {showReplyButton && (
+ {!message.is_aggregated && (
)}
+ )}
)}
+ )}
+ {[ + { mode: 'panel', icon: , label: 'Panel' }, + { mode: 'aggregated', icon: , label: 'Aggregated' }, + ].map(({ mode, icon, label }) => { + const active = responseMode === mode; + return ( + + ); + })} +
{/* Right - Mic + Send */} diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index b5c42277..b19b1e3c 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -992,7 +992,7 @@ const handleNewChat = async (sessionId = null) => { Panel
)} From 03cfbf441dc14bbe5eaed6f98ac94a1b26c1b9d7 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Mon, 8 Jun 2026 16:30:00 -0700 Subject: [PATCH 13/15] fix on demand aggregation request from appending to end of chat history. --- phd-advisor-frontend/src/pages/ChatPage.js | 67 +++++++++++++--------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index b19b1e3c..45a2f586 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -53,12 +53,13 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig try { localStorage.setItem('responseMode', responseMode); } catch { /* non-fatal */ } }, [responseMode]); - // Per-exchange view override: { [groupId]: 'panel' | 'aggregated' }. + // Per-exchange view override: { [responseGroupId]: 'panel' | 'aggregated' }. // Lets the user flip an individual exchange between the advisor panel and // the single combined answer, independently of the global default. const [groupViews, setGroupViews] = useState({}); // Group ids currently running an on-demand synthesis pass (for lag UX). const [synthesizingGroups, setSynthesizingGroups] = useState({}); + const skipAutoScrollRef = useRef(false); @@ -71,6 +72,10 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig }; useEffect(() => { + if (skipAutoScrollRef.current) { + skipAutoScrollRef.current = false; + return; + } scrollToBottom(); }, [messages, thinkingAdvisors]); @@ -222,7 +227,8 @@ const loadChatSession = async (sessionId) => { const formattedMessages = result.context.messages.map(msg => ({ ...msg, timestamp: new Date(msg.timestamp), - persona_id: msg.persona_id || msg.advisor || msg.advisorId + persona_id: msg.persona_id || msg.advisor || msg.advisorId, + responseGroupId: msg.response_group_id || null, })); setMessages(formattedMessages); @@ -397,8 +403,8 @@ const handleNewChat = async (sessionId = null) => { return response; }; - const handleToggleGroupView = async (groupId, panelMessages, hasAggregated) => { - const current = groupViews[groupId] || (hasAggregated ? 'aggregated' : 'panel'); + const handleToggleGroupView = async (responseGroupId, panelMessages, hasAggregated) => { + const current = groupViews[responseGroupId] || (hasAggregated ? 'aggregated' : 'panel'); const next = current === 'panel' ? 'aggregated' : 'panel'; if (next === 'aggregated' && !hasAggregated) { @@ -409,7 +415,7 @@ const handleNewChat = async (sessionId = null) => { if (messages[i].type === 'user') { userPrompt = messages[i].content; break; } } - setSynthesizingGroups(prev => ({ ...prev, [groupId]: true })); + setSynthesizingGroups(prev => ({ ...prev, [responseGroupId]: true })); try { const resp = await fetch(`${process.env.REACT_APP_API_URL}/request-aggregated-response`, { method: 'POST', @@ -427,7 +433,7 @@ const handleNewChat = async (sessionId = null) => { document_chunks_used: m.document_chunks_used || 0, })), chat_session_id: currentSessionId, - response_group_id: groupId, + response_group_id: responseGroupId, }), }); @@ -441,23 +447,32 @@ const handleNewChat = async (sessionId = null) => { timestamp: new Date(), advisorName: data.persona_name, is_aggregated: true, - groupId, + responseGroupId, source_personas: data.source_personas, }; - setMessages(prev => [...prev, mergedMsg]); - setGroupViews(prev => ({ ...prev, [groupId]: 'aggregated' })); + skipAutoScrollRef.current = true; + setMessages(prev => { + const lastPanelIdx = prev.findLastIndex( + m => m.responseGroupId === responseGroupId && !m.is_aggregated + ); + if (lastPanelIdx === -1) return [...prev, mergedMsg]; + const updated = [...prev]; + updated.splice(lastPanelIdx + 1, 0, mergedMsg); + return updated; + }); + setGroupViews(prev => ({ ...prev, [responseGroupId]: 'aggregated' })); } else { console.error('Synthesis request failed:', resp.status); } } catch (err) { console.error('On-demand synthesis failed:', err); } finally { - setSynthesizingGroups(prev => { const n = { ...prev }; delete n[groupId]; return n; }); + setSynthesizingGroups(prev => { const n = { ...prev }; delete n[responseGroupId]; return n; }); } return; } - setGroupViews(prev => ({ ...prev, [groupId]: next })); + setGroupViews(prev => ({ ...prev, [responseGroupId]: next })); }; const handleSendMessage = async (inputMessage) => { @@ -497,7 +512,7 @@ const handleNewChat = async (sessionId = null) => { setThinkingAdvisors(['system']); const aggregatedMode = responseMode === 'aggregated'; - const groupId = 'grp_' + generateMessageId(); + const responseGroupId = 'grp_' + generateMessageId(); // Always collect this exchange's advisor responses so the panel is stored // even when aggregated mode is the default — the user can toggle to it. const collectedAdvisorResponses = []; @@ -540,7 +555,7 @@ const handleNewChat = async (sessionId = null) => { advisorName: d.persona_name || d.persona_id, used_documents: d.used_documents || false, document_chunks_used: d.document_chunks_used || 0, - groupId, + responseGroupId, is_aggregated: d.is_aggregated || false, source_personas: d.source_personas || null, }; @@ -565,7 +580,7 @@ const handleNewChat = async (sessionId = null) => { break; } if (d.phase === 'synthesizing') { - setSynthesizingGroups(prev => ({ ...prev, [groupId]: true })); + setSynthesizingGroups(prev => ({ ...prev, [responseGroupId]: true })); } if (d.persona_id != null) { setThinkingAdvisors(prev => prev.filter(a => a !== d.persona_id)); @@ -592,8 +607,8 @@ const handleNewChat = async (sessionId = null) => { setMessages(prev => [...prev, ...deferred]); } } - setGroupViews(prev => ({ ...prev, [groupId]: hasAggregated ? 'aggregated' : 'panel' })); - setSynthesizingGroups(prev => { const n = { ...prev }; delete n[groupId]; return n; }); + setGroupViews(prev => ({ ...prev, [responseGroupId]: hasAggregated ? 'aggregated' : 'panel' })); + setSynthesizingGroups(prev => { const n = { ...prev }; delete n[responseGroupId]; return n; }); } catch (error) { console.error('Error sending message:', error); @@ -810,15 +825,15 @@ const handleNewChat = async (sessionId = null) => { } const aggregatedMessages = advisorGroup.filter(m => m.is_aggregated); const panelMessages = advisorGroup.filter(m => !m.is_aggregated); - // Prefer the shared groupId stamped at creation; fall back to a stable - // id derived from the message ids so historical/legacy exchanges - // (saved before groupId existed) can still be toggled. - const groupId = - advisorGroup.find(m => m.groupId)?.groupId || + // Prefer the shared responseGroupId stamped at creation; fall back to + // a stable id derived from the message ids so historical/legacy + // exchanges (saved before responseGroupId existed) can still be toggled. + const responseGroupId = + advisorGroup.find(m => m.responseGroupId)?.responseGroupId || `legacy_${advisorGroup.map(m => m.id).join('_')}`; groups.push({ type: 'advisor_group', - groupId, + responseGroupId, messages: advisorGroup, panelMessages, aggregatedMessages, @@ -957,14 +972,14 @@ const handleNewChat = async (sessionId = null) => { {messageGroups.map((group) => ( group.type === 'advisor_group' ? (() => { const hasAggregated = group.aggregatedMessages.length > 0; - const view = groupViews[group.groupId] || (hasAggregated ? 'aggregated' : 'panel'); - const isSynth = !!synthesizingGroups[group.groupId]; + const view = groupViews[group.responseGroupId] || (hasAggregated ? 'aggregated' : 'panel'); + const isSynth = !!synthesizingGroups[group.responseGroupId]; const showAggregated = view === 'aggregated' && hasAggregated; const shown = showAggregated ? group.aggregatedMessages : group.panelMessages; const showToggle = group.panelMessages.length > 1 || hasAggregated || isSynth; const switchTo = (target) => { if ((target === 'aggregated') !== showAggregated) { - handleToggleGroupView(group.groupId, group.panelMessages, hasAggregated); + handleToggleGroupView(group.responseGroupId, group.panelMessages, hasAggregated); } }; const segBtn = (active) => ({ @@ -976,7 +991,7 @@ const handleNewChat = async (sessionId = null) => { color: active ? '#fff' : 'var(--text-secondary)', }); return ( -
+
{showToggle && (
Date: Mon, 8 Jun 2026 18:10:43 -0700 Subject: [PATCH 14/15] fix to prevent a React key collision when legacy and new exchanges share a chat session. --- phd-advisor-frontend/src/pages/ChatPage.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index 45a2f586..aed4197e 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -825,11 +825,11 @@ const handleNewChat = async (sessionId = null) => { } const aggregatedMessages = advisorGroup.filter(m => m.is_aggregated); const panelMessages = advisorGroup.filter(m => !m.is_aggregated); - // Prefer the shared responseGroupId stamped at creation; fall back to - // a stable id derived from the message ids so historical/legacy - // exchanges (saved before responseGroupId existed) can still be toggled. + // Prefer the shared responseGroupId from non-aggregated messages; + // aggregated messages appended to the DB can land after a later + // exchange's advisors, so they must not pollute that group's key. const responseGroupId = - advisorGroup.find(m => m.responseGroupId)?.responseGroupId || + advisorGroup.find(m => m.responseGroupId && !m.is_aggregated)?.responseGroupId || `legacy_${advisorGroup.map(m => m.id).join('_')}`; groups.push({ type: 'advisor_group', From 5302992489fdcafdf80fd47da23a9cb0b6247e01 Mon Sep 17 00:00:00 2001 From: Charlie Bailey Date: Mon, 8 Jun 2026 18:17:57 -0700 Subject: [PATCH 15/15] fix to hide the Panel/Aggregated toggle on legacy exchanges that don't have a responseGroupId. --- phd-advisor-frontend/src/pages/ChatPage.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/phd-advisor-frontend/src/pages/ChatPage.js b/phd-advisor-frontend/src/pages/ChatPage.js index aed4197e..2e26c86d 100644 --- a/phd-advisor-frontend/src/pages/ChatPage.js +++ b/phd-advisor-frontend/src/pages/ChatPage.js @@ -976,7 +976,8 @@ const handleNewChat = async (sessionId = null) => { const isSynth = !!synthesizingGroups[group.responseGroupId]; const showAggregated = view === 'aggregated' && hasAggregated; const shown = showAggregated ? group.aggregatedMessages : group.panelMessages; - const showToggle = group.panelMessages.length > 1 || hasAggregated || isSynth; + const isLegacy = group.responseGroupId.startsWith('legacy_'); + const showToggle = !isLegacy && (group.panelMessages.length > 1 || hasAggregated || isSynth); const switchTo = (target) => { if ((target === 'aggregated') !== showAggregated) { handleToggleGroupView(group.responseGroupId, group.panelMessages, hasAggregated);