Girish Jeswani commited on
Commit
1e986af
·
2 Parent(s): 6e93148ef8ae4e

Merge pull request #47 from sohank-17/girish-feature

Browse files
multi_llm_chatbot_backend/app/api/routes.py CHANGED
@@ -1,26 +1,25 @@
1
  import os
2
- from fastapi import APIRouter, Body, HTTPException
 
3
  import httpx
4
  from app.llm.llm_client import LLMClient
5
- from app.llm.gemini_client import GeminiClient
6
- from app.llm.short_ollama_client import ShortResponseOllamaClient
7
  from app.models.persona import Persona
8
- from app.core.orchestrator import ChatOrchestrator
9
- from app.core.seamless_orchestrator import SeamlessOrchestrator
10
- from app.core.context import GlobalSessionContext
11
  from app.models.default_personas import get_default_personas
12
- from pydantic import BaseModel
13
- from typing import Optional, List
14
- from fastapi import UploadFile, File
15
  from app.utils.document_extractor import extract_text_from_file
16
- from app.core.orchestrator import answer_with_persona_context
17
- from app.utils.chroma_client import add_persona_doc
18
- import hashlib
19
  from app.utils.file_limits import is_within_upload_limit
 
 
 
 
 
20
 
21
  router = APIRouter()
22
 
23
- # Provider management
24
  current_provider = "gemini"
25
  available_providers = ["ollama", "gemini"]
26
 
@@ -31,30 +30,26 @@ def create_llm_client(provider: str = None) -> LLMClient:
31
 
32
  if provider == "gemini":
33
  try:
34
- return GeminiClient(model_name=os.getenv("GEMINI_MODEL"))
35
  except ValueError as e:
36
- # Fallback to Ollama if Gemini API key is not available
37
- print(f"Gemini API key not found, falling back to Ollama: {e}")
38
- return ShortResponseOllamaClient(model_name="llama3.2:1b")
39
  elif provider == "ollama":
40
- return ShortResponseOllamaClient(model_name="llama3.2:1b")
41
  else:
42
  raise ValueError(f"Unknown provider: {provider}")
43
 
44
  # Initialize with default provider
45
  llm = create_llm_client()
46
- chat_orchestrator = ChatOrchestrator()
47
- seamless_orchestrator = SeamlessOrchestrator(llm=llm)
48
-
49
- session_context = GlobalSessionContext()
50
 
51
  # Initialize personas
52
  DEFAULT_PERSONAS = get_default_personas(llm)
53
-
54
  for persona in DEFAULT_PERSONAS:
55
  chat_orchestrator.register_persona(persona)
56
 
57
- # Data models
58
  class UserInput(BaseModel):
59
  user_input: str
60
 
@@ -76,16 +71,52 @@ class ReplyToAdvisor(BaseModel):
76
  class ProviderSwitch(BaseModel):
77
  provider: str
78
 
79
- # Helper functions for response validation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  def _is_valid_response(response: str, persona_id: str) -> bool:
81
  """Validate response quality"""
82
  if len(response) < 2 or len(response) > 5000:
83
  return False
84
 
85
- # Check for AI confusion indicators
86
  confusion_indicators = [
87
  f"Thank you, Dr. {persona_id.title()}",
88
  "Assistant:",
 
 
89
  f"Dr. {persona_id.title()} Advisor:",
90
  "excellent discussion, Assistant"
91
  ]
@@ -101,7 +132,7 @@ def _get_persona_fallback(persona_id: str) -> str:
101
  }
102
  return fallbacks.get(persona_id, "I'd be happy to help. Could you provide more details?")
103
 
104
- # Provider management endpoints
105
  @router.get("/current-provider")
106
  async def get_current_provider():
107
  return {
@@ -124,22 +155,15 @@ async def switch_provider(provider_data: ProviderSwitch):
124
  )
125
 
126
  try:
127
- # Update current provider
128
  current_provider = provider_data.provider
129
-
130
- # Create new LLM client
131
  new_llm = create_llm_client(current_provider)
132
  llm = new_llm
133
 
134
- # Update all personas with new LLM
135
  new_personas = get_default_personas(new_llm)
136
  chat_orchestrator.personas.clear()
137
  for persona in new_personas:
138
  chat_orchestrator.register_persona(persona)
139
 
140
- # Update seamless orchestrator
141
- seamless_orchestrator.llm = new_llm
142
-
143
  return {
144
  "message": f"Successfully switched to {current_provider}",
145
  "current_provider": current_provider,
@@ -155,79 +179,61 @@ async def switch_provider(provider_data: ProviderSwitch):
155
  detail=f"Failed to switch to {provider_data.provider}: {str(e)}"
156
  )
157
 
158
- # Sequential advisor responses endpoint
159
  @router.post("/chat-sequential")
160
- async def chat_sequential(message: ChatMessage):
161
- """Generate advisor responses with improved quality controls"""
162
-
 
 
163
  try:
164
- orchestrator_result = await seamless_orchestrator.process_message(message.user_input)
165
-
166
- if orchestrator_result["status"] == "orchestrator_asking":
 
 
 
 
 
 
 
 
 
167
  return {
168
  "type": "orchestrator_question",
169
  "responses": [{
170
  "persona": "PhD Advisor Assistant",
171
- "response": orchestrator_result["orchestrator_question"]
172
  }],
173
- "collected_info": orchestrator_result["collected_info"]
174
  }
175
-
176
- elif orchestrator_result["status"] == "ready_for_advisors":
177
- enhanced_context = orchestrator_result["enhanced_context"]
178
-
179
- # Clear previous advisor responses to avoid confusion
180
- session_context.clear()
181
- session_context.append("user", message.user_input)
182
- session_context.append("orchestrator", enhanced_context)
183
-
184
- advisor_order = chat_orchestrator.get_response_order()
185
- print("Advisor Order:")
186
- print(advisor_order)
187
- responses = []
188
-
189
- for persona_id in advisor_order:
190
- try:
191
- persona = chat_orchestrator.personas[persona_id]
192
- reply = await persona.respond(session_context.full_log, response_length="medium")
193
- print("Replies:")
194
- print(reply)
195
-
196
- # Validate response before adding
197
- if _is_valid_response(reply, persona_id):
198
- responses.append({
199
- "persona": persona.name,
200
- "persona_id": persona_id,
201
- "response": reply,
202
- })
203
- else:
204
- # Fallback response for invalid responses
205
- responses.append({
206
- "persona": persona.name,
207
- "persona_id": persona_id,
208
- "response": _get_persona_fallback(persona_id),
209
- })
210
-
211
- session_context.append(persona_id, reply)
212
-
213
- except Exception as e:
214
- print(f"Error generating response for {persona_id}: {e}")
215
- responses.append({
216
- "persona": chat_orchestrator.personas[persona_id].name,
217
- "persona_id": persona_id,
218
- "response": _get_persona_fallback(persona_id),
219
- })
220
-
221
- print("Response Block: " )
222
- print(responses)
223
  return {
224
- "type": "sequential_responses",
225
- "responses": responses,
226
- "collected_info": orchestrator_result["collected_info"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  }
228
 
229
  except Exception as e:
230
- print(f"Error in chat_sequential: {e}")
231
  return {
232
  "type": "error",
233
  "responses": [{
@@ -236,109 +242,94 @@ async def chat_sequential(message: ChatMessage):
236
  }]
237
  }
238
 
239
-
240
- # Individual advisor endpoint with context
241
  @router.post("/chat/{persona_id}")
242
- async def chat_with_specific_advisor(persona_id: str, input: UserInput):
243
- """Chat with a specific advisor"""
244
  try:
245
  if persona_id not in chat_orchestrator.personas:
246
  raise HTTPException(status_code=404, detail=f"Persona '{persona_id}' not found")
247
 
248
- session_context.append("user", input.user_input)
249
- persona = chat_orchestrator.personas[persona_id]
250
- context = session_context.full_log.copy()
251
- reply = await persona.respond(context, response_length="medium")
252
- session_context.append(persona_id, reply)
253
-
254
- return {
255
- "persona": persona.name,
256
- "persona_id": persona_id,
257
- "response": reply
258
- }
 
 
 
 
 
 
 
 
 
 
 
 
259
  except HTTPException:
260
  raise
261
  except Exception as e:
262
- print(f"Error in chat_with_specific_advisor: {e}")
263
  return {
264
  "persona": "System",
265
  "response": "I'm having trouble generating a response right now. Please try again."
266
  }
267
 
268
- # Reply to specific advisor endpoint
269
  @router.post("/reply-to-advisor")
270
- async def reply_to_advisor(reply: ReplyToAdvisor):
271
- """Reply to a specific advisor and get response only from that advisor"""
272
-
273
  try:
274
  if reply.advisor_id not in chat_orchestrator.personas:
275
  raise HTTPException(status_code=404, detail=f"Advisor '{reply.advisor_id}' not found")
276
 
277
- # Add user reply to context
278
- session_context.append("user", reply.user_input)
279
-
280
- # Get response from specific advisor
281
- persona = chat_orchestrator.personas[reply.advisor_id]
282
 
283
- # Generate response
284
- reply_response = await persona.respond(session_context.full_log, response_length="medium")
285
- session_context.append(reply.advisor_id, reply_response)
 
 
 
286
 
287
- return {
288
- "type": "advisor_reply",
289
- "persona": persona.name,
290
- "persona_id": reply.advisor_id,
291
- "response": reply_response,
292
- "original_message_id": reply.original_message_id
293
- }
 
 
 
 
 
 
 
 
294
 
295
  except HTTPException:
296
  raise
297
  except Exception as e:
298
- print(f"Error in reply_to_advisor: {e}")
299
  return {
300
  "type": "error",
301
  "persona": "System",
302
  "response": "I'm having trouble generating a reply right now. Please try again."
303
  }
304
 
305
- # Reset session
306
- @router.post("/reset-session")
307
- async def reset_session():
308
- try:
309
- seamless_orchestrator.reset()
310
- session_context.clear()
311
- return {"status": "reset", "message": "Session reset successfully"}
312
- except Exception as e:
313
- print(f"Error resetting session: {e}")
314
- return {"status": "error", "message": "Failed to reset session"}
315
-
316
- # Context inspection
317
- @router.get("/context")
318
- def get_context():
319
- return session_context.full_log
320
-
321
- # Legacy model switching endpoint (now redirects to provider switching)
322
- @router.post("/switch-model")
323
- async def switch_model(model_name: str = Body(...)):
324
- # For backward compatibility, try to map model names to providers
325
- if "gemini" in model_name.lower():
326
- return await switch_provider(ProviderSwitch(provider="gemini"))
327
- else:
328
- return await switch_provider(ProviderSwitch(provider="ollama"))
329
-
330
- @router.get("/current-model")
331
- async def get_current_model():
332
- # For backward compatibility
333
- model_name = llm.model_name if hasattr(llm, 'model_name') else "gemini-2.0-flash"
334
- return {
335
- "model": model_name,
336
- "provider": current_provider
337
- }
338
-
339
  @router.post("/upload-document")
340
- async def upload_document(file: UploadFile = File(...)):
341
- # Validate file type
342
  if file.content_type not in [
343
  "application/pdf",
344
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -347,58 +338,157 @@ async def upload_document(file: UploadFile = File(...)):
347
  raise HTTPException(status_code=400, detail="Unsupported file type.")
348
 
349
  try:
350
- # Read file bytes
 
 
 
351
  file_bytes = await file.read()
352
 
353
- # Check file size limit
354
- if not is_within_upload_limit("default", file_bytes, session_context):
 
355
  raise HTTPException(status_code=400, detail="Upload exceeds session document size limit (10 MB).")
356
 
357
- # Extract and validate text
358
  content = extract_text_from_file(file_bytes, file.content_type)
359
  if not content.strip():
360
  raise HTTPException(status_code=400, detail="Document is empty or unreadable.")
361
 
362
- # Track file size and name
363
- session_context.append("Document", f"[Uploaded Document Content]\n{content.strip()}")
364
- session_context.uploaded_files.append(file.filename)
365
- session_context.total_upload_size += len(file_bytes)
366
 
367
  return {"message": "Document uploaded and added to context successfully."}
368
 
 
 
369
  except Exception as e:
 
370
  raise HTTPException(status_code=500, detail=f"Error processing document: {str(e)}")
371
 
372
- # Debug endpoint
373
- @router.get("/debug/personas")
374
- async def debug_personas():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  return {
376
- "personas": {
377
- pid: {
378
- "name": persona.name,
379
- "prompt": persona.system_prompt[:100] + "..."
380
- } for pid, persona in chat_orchestrator.personas.items()
381
- },
382
- "context_length": len(session_context.full_log),
383
- "current_provider": current_provider
384
  }
385
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  class PersonaQuery(BaseModel):
387
  question: str
388
  persona: str
389
 
390
  @router.post("/ask/")
391
- async def ask_question(query: PersonaQuery):
392
- response = await answer_with_persona_context(query.question, query.persona)
393
-
394
- # Store Q&A in vector DB
395
- combined_text = f"Q: {query.question}\nA: {response}"
396
- doc_id = hashlib.md5(combined_text.encode()).hexdigest() # Create a unique doc ID
397
-
398
- add_persona_doc(combined_text, query.persona, doc_id)
399
-
400
- return {"response": response}
 
 
 
 
 
 
 
 
 
 
 
 
401
 
402
- @router.get("/uploaded-files")
403
- def get_uploaded_filenames():
404
- return {"files": session_context.uploaded_files}
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ from fastapi import APIRouter, Body, HTTPException, Header, UploadFile, File, Request
3
+ from typing import Optional, List
4
  import httpx
5
  from app.llm.llm_client import LLMClient
6
+ from app.llm.improved_gemini_client import ImprovedGeminiClient
7
+ from app.llm.improved_ollama_client import ImprovedOllamaClient
8
  from app.models.persona import Persona
9
+ from app.core.improved_orchestrator import ImprovedChatOrchestrator
10
+ from app.core.session_manager import get_session_manager
 
11
  from app.models.default_personas import get_default_personas
 
 
 
12
  from app.utils.document_extractor import extract_text_from_file
 
 
 
13
  from app.utils.file_limits import is_within_upload_limit
14
+ from pydantic import BaseModel
15
+ import hashlib
16
+ import logging
17
+
18
+ logger = logging.getLogger(__name__)
19
 
20
  router = APIRouter()
21
 
22
+ # Provider management (same as before)
23
  current_provider = "gemini"
24
  available_providers = ["ollama", "gemini"]
25
 
 
30
 
31
  if provider == "gemini":
32
  try:
33
+ return ImprovedGeminiClient(model_name=os.getenv("GEMINI_MODEL"))
34
  except ValueError as e:
35
+ logger.warning(f"Gemini API key not found, falling back to Ollama: {e}")
36
+ return ImprovedOllamaClient(model_name="llama3.2:1b")
 
37
  elif provider == "ollama":
38
+ return ImprovedOllamaClient(model_name="llama3.2:1b")
39
  else:
40
  raise ValueError(f"Unknown provider: {provider}")
41
 
42
  # Initialize with default provider
43
  llm = create_llm_client()
44
+ chat_orchestrator = ImprovedChatOrchestrator()
45
+ session_manager = get_session_manager()
 
 
46
 
47
  # Initialize personas
48
  DEFAULT_PERSONAS = get_default_personas(llm)
 
49
  for persona in DEFAULT_PERSONAS:
50
  chat_orchestrator.register_persona(persona)
51
 
52
+ # Keep all the same data models as before
53
  class UserInput(BaseModel):
54
  user_input: str
55
 
 
71
  class ProviderSwitch(BaseModel):
72
  provider: str
73
 
74
+ # ==============================================================
75
+ # SESSION MANAGEMENT COMPATIBILITY LAYER
76
+ # ==============================================================
77
+
78
+ def get_or_create_session_for_request(request: Request,
79
+ session_id_override: Optional[str] = None) -> str:
80
+ """
81
+ Get or create session for request using multiple strategies:
82
+ 1. Use provided session_id if given
83
+ 2. Use X-Session-ID header if present
84
+ 3. Use client IP as fallback for backward compatibility
85
+ 4. Create new session if nothing available
86
+
87
+ This allows the old stateless API to work with session management
88
+ """
89
+ # Strategy 1: Explicit session ID (for new clients)
90
+ if session_id_override:
91
+ return session_id_override
92
+
93
+ # Strategy 2: Check for session header (optional for frontend)
94
+ session_header = request.headers.get("X-Session-ID")
95
+ if session_header:
96
+ return session_header
97
+
98
+ # Strategy 3: Use client IP for backward compatibility
99
+ # This gives each client IP their own persistent session
100
+ client_ip = request.client.host if request.client else "unknown"
101
+ ip_session_id = f"ip_{client_ip}"
102
+
103
+ # Get or create session for this IP
104
+ session = session_manager.get_session(ip_session_id)
105
+ return session.session_id
106
+
107
+
108
+
109
+ # Helper functions (same as before)
110
  def _is_valid_response(response: str, persona_id: str) -> bool:
111
  """Validate response quality"""
112
  if len(response) < 2 or len(response) > 5000:
113
  return False
114
 
 
115
  confusion_indicators = [
116
  f"Thank you, Dr. {persona_id.title()}",
117
  "Assistant:",
118
+ f"Dr. {persona_id.title()}",
119
+ "Assistant:",
120
  f"Dr. {persona_id.title()} Advisor:",
121
  "excellent discussion, Assistant"
122
  ]
 
132
  }
133
  return fallbacks.get(persona_id, "I'd be happy to help. Could you provide more details?")
134
 
135
+ # Provider management endpoints (EXACTLY THE SAME)
136
  @router.get("/current-provider")
137
  async def get_current_provider():
138
  return {
 
155
  )
156
 
157
  try:
 
158
  current_provider = provider_data.provider
 
 
159
  new_llm = create_llm_client(current_provider)
160
  llm = new_llm
161
 
 
162
  new_personas = get_default_personas(new_llm)
163
  chat_orchestrator.personas.clear()
164
  for persona in new_personas:
165
  chat_orchestrator.register_persona(persona)
166
 
 
 
 
167
  return {
168
  "message": f"Successfully switched to {current_provider}",
169
  "current_provider": current_provider,
 
179
  detail=f"Failed to switch to {provider_data.provider}: {str(e)}"
180
  )
181
 
182
+ # Main chat endpoint (SAME INTERFACE, improved backend)
183
  @router.post("/chat-sequential")
184
+ async def chat_sequential(message: ChatMessage, request: Request):
185
+ """
186
+ SAME INTERFACE AS BEFORE - Generate advisor responses
187
+ Now with improved session management behind the scenes
188
+ """
189
  try:
190
+ # Get session using compatibility layer
191
+ session_id = get_or_create_session_for_request(request, message.session_id)
192
+
193
+ # Use the new orchestrator with session management
194
+ result = await chat_orchestrator.process_message(
195
+ user_input=message.user_input,
196
+ session_id=session_id,
197
+ response_length=message.response_length
198
+ )
199
+
200
+ # Convert new format back to old format for backward compatibility
201
+ if result["type"] == "clarification":
202
  return {
203
  "type": "orchestrator_question",
204
  "responses": [{
205
  "persona": "PhD Advisor Assistant",
206
+ "response": result["message"]
207
  }],
208
+ "collected_info": {}
209
  }
210
+
211
+ elif result["type"] == "persona_responses":
212
+ # Convert new response format to old format
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  return {
214
+ "type": "sequential_responses",
215
+ "responses": [
216
+ {
217
+ "persona": resp["persona_name"],
218
+ "persona_id": resp["persona_id"],
219
+ "response": resp["response"]
220
+ }
221
+ for resp in result["responses"]
222
+ ],
223
+ "collected_info": {}
224
+ }
225
+
226
+ else:
227
+ return {
228
+ "type": "error",
229
+ "responses": [{
230
+ "persona": "System",
231
+ "response": result.get("message", "Please try again.")
232
+ }]
233
  }
234
 
235
  except Exception as e:
236
+ logger.error(f"Error in chat_sequential: {e}")
237
  return {
238
  "type": "error",
239
  "responses": [{
 
242
  }]
243
  }
244
 
245
+ # Individual advisor endpoint (SAME INTERFACE)
 
246
  @router.post("/chat/{persona_id}")
247
+ async def chat_with_specific_advisor(persona_id: str, input: UserInput, request: Request):
248
+ """Chat with a specific advisor - SAME INTERFACE"""
249
  try:
250
  if persona_id not in chat_orchestrator.personas:
251
  raise HTTPException(status_code=404, detail=f"Persona '{persona_id}' not found")
252
 
253
+ # Get session using compatibility layer
254
+ session_id = get_or_create_session_for_request(request)
255
+
256
+ # Use new orchestrator
257
+ result = await chat_orchestrator.chat_with_persona(
258
+ user_input=input.user_input,
259
+ persona_id=persona_id,
260
+ session_id=session_id
261
+ )
262
+
263
+ if result["type"] == "single_persona_response":
264
+ persona_data = result["persona"]
265
+ return {
266
+ "persona": persona_data["persona_name"],
267
+ "persona_id": persona_data["persona_id"],
268
+ "response": persona_data["response"]
269
+ }
270
+ else:
271
+ return {
272
+ "persona": "System",
273
+ "response": result.get("message", "I'm having trouble generating a response right now. Please try again.")
274
+ }
275
+
276
  except HTTPException:
277
  raise
278
  except Exception as e:
279
+ logger.error(f"Error in chat_with_specific_advisor: {e}")
280
  return {
281
  "persona": "System",
282
  "response": "I'm having trouble generating a response right now. Please try again."
283
  }
284
 
285
+ # Reply to advisor endpoint (SAME INTERFACE)
286
  @router.post("/reply-to-advisor")
287
+ async def reply_to_advisor(reply: ReplyToAdvisor, request: Request):
288
+ """Reply to a specific advisor - SAME INTERFACE"""
 
289
  try:
290
  if reply.advisor_id not in chat_orchestrator.personas:
291
  raise HTTPException(status_code=404, detail=f"Advisor '{reply.advisor_id}' not found")
292
 
293
+ # Get session using compatibility layer
294
+ session_id = get_or_create_session_for_request(request)
 
 
 
295
 
296
+ # Use new orchestrator
297
+ result = await chat_orchestrator.chat_with_persona(
298
+ user_input=reply.user_input,
299
+ persona_id=reply.advisor_id,
300
+ session_id=session_id
301
+ )
302
 
303
+ if result["type"] == "single_persona_response":
304
+ persona_data = result["persona"]
305
+ return {
306
+ "type": "advisor_reply",
307
+ "persona": persona_data["persona_name"],
308
+ "persona_id": persona_data["persona_id"],
309
+ "response": persona_data["response"],
310
+ "original_message_id": reply.original_message_id
311
+ }
312
+ else:
313
+ return {
314
+ "type": "error",
315
+ "persona": "System",
316
+ "response": result.get("message", "I'm having trouble generating a reply right now. Please try again.")
317
+ }
318
 
319
  except HTTPException:
320
  raise
321
  except Exception as e:
322
+ logger.error(f"Error in reply_to_advisor: {e}")
323
  return {
324
  "type": "error",
325
  "persona": "System",
326
  "response": "I'm having trouble generating a reply right now. Please try again."
327
  }
328
 
329
+ # Document upload (SAME INTERFACE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  @router.post("/upload-document")
331
+ async def upload_document(file: UploadFile = File(...), request: Request = None):
332
+ """Upload document - SAME INTERFACE"""
333
  if file.content_type not in [
334
  "application/pdf",
335
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
 
338
  raise HTTPException(status_code=400, detail="Unsupported file type.")
339
 
340
  try:
341
+ # Get session using compatibility layer
342
+ session_id = get_or_create_session_for_request(request)
343
+ session = session_manager.get_session(session_id)
344
+
345
  file_bytes = await file.read()
346
 
347
+ # Simple size check
348
+ MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
349
+ if len(file_bytes) > MAX_FILE_SIZE:
350
  raise HTTPException(status_code=400, detail="Upload exceeds session document size limit (10 MB).")
351
 
 
352
  content = extract_text_from_file(file_bytes, file.content_type)
353
  if not content.strip():
354
  raise HTTPException(status_code=400, detail="Document is empty or unreadable.")
355
 
356
+ # Add to session context using new system
357
+ session.add_uploaded_file(file.filename, content, len(file_bytes))
 
 
358
 
359
  return {"message": "Document uploaded and added to context successfully."}
360
 
361
+ except HTTPException:
362
+ raise
363
  except Exception as e:
364
+ logger.error(f"Error uploading document: {str(e)}")
365
  raise HTTPException(status_code=500, detail=f"Error processing document: {str(e)}")
366
 
367
+ # Get uploaded files (SAME INTERFACE)
368
+ @router.get("/uploaded-files")
369
+ async def get_uploaded_filenames(request: Request):
370
+ """Get uploaded files - SAME INTERFACE"""
371
+ try:
372
+ session_id = get_or_create_session_for_request(request)
373
+ session = session_manager.get_session(session_id)
374
+ return {"files": session.uploaded_files}
375
+ except Exception as e:
376
+ logger.error(f"Error getting uploaded files: {str(e)}")
377
+ return {"files": []}
378
+
379
+ # Context endpoint (SAME INTERFACE)
380
+ @router.get("/context")
381
+ async def get_context(request: Request):
382
+ """Get context - SAME INTERFACE"""
383
+ try:
384
+ session_id = get_or_create_session_for_request(request)
385
+ session = session_manager.get_session(session_id)
386
+ return session.messages # Return messages in same format as before
387
+ except Exception as e:
388
+ logger.error(f"Error getting context: {str(e)}")
389
+ return []
390
+
391
+ # Reset session (SAME INTERFACE)
392
+ @router.post("/reset-session")
393
+ async def reset_session(request: Request):
394
+ """Reset session - SAME INTERFACE"""
395
+ try:
396
+ session_id = get_or_create_session_for_request(request)
397
+ success = chat_orchestrator.reset_session(session_id)
398
+
399
+ if success:
400
+ return {"status": "reset", "message": "Session reset successfully"}
401
+ else:
402
+ return {"status": "error", "message": "Failed to reset session"}
403
+ except Exception as e:
404
+ logger.error(f"Error resetting session: {e}")
405
+ return {"status": "error", "message": "Failed to reset session"}
406
+
407
+ # Legacy model endpoints (SAME INTERFACE)
408
+ @router.post("/switch-model")
409
+ async def switch_model(model_name: str = Body(...)):
410
+ """Legacy model switching - SAME INTERFACE"""
411
+ if "gemini" in model_name.lower():
412
+ return await switch_provider(ProviderSwitch(provider="gemini"))
413
+ else:
414
+ return await switch_provider(ProviderSwitch(provider="ollama"))
415
+
416
+ @router.get("/current-model")
417
+ async def get_current_model():
418
+ """Legacy model info - SAME INTERFACE"""
419
+ model_name = llm.model_name if hasattr(llm, 'model_name') else "gemini-2.0-flash"
420
  return {
421
+ "model": model_name,
422
+ "provider": current_provider
 
 
 
 
 
 
423
  }
424
 
425
+ # Debug endpoint (SAME INTERFACE)
426
+ @router.get("/debug/personas")
427
+ async def debug_personas(request: Request):
428
+ """Debug personas - SAME INTERFACE"""
429
+ try:
430
+ session_id = get_or_create_session_for_request(request)
431
+ session = session_manager.get_session(session_id)
432
+
433
+ return {
434
+ "personas": {
435
+ pid: {
436
+ "name": persona.name,
437
+ "prompt": persona.system_prompt[:100] + "..."
438
+ } for pid, persona in chat_orchestrator.personas.items()
439
+ },
440
+ "context_length": len(session.messages),
441
+ "current_provider": current_provider
442
+ }
443
+ except Exception as e:
444
+ logger.error(f"Error in debug endpoint: {str(e)}")
445
+ return {
446
+ "personas": {},
447
+ "context_length": 0,
448
+ "current_provider": current_provider
449
+ }
450
+
451
+ # Ask endpoint (SAME INTERFACE)
452
  class PersonaQuery(BaseModel):
453
  question: str
454
  persona: str
455
 
456
  @router.post("/ask/")
457
+ async def ask_question(query: PersonaQuery, request: Request):
458
+ """Ask question - SAME INTERFACE"""
459
+ try:
460
+ session_id = get_or_create_session_for_request(request)
461
+
462
+ # Use the new orchestrator
463
+ result = await chat_orchestrator.chat_with_persona(
464
+ user_input=query.question,
465
+ persona_id=query.persona,
466
+ session_id=session_id
467
+ )
468
+
469
+ if result["type"] == "single_persona_response":
470
+ response_text = result["persona"]["response"]
471
+ else:
472
+ response_text = result.get("message", "I'm having trouble responding right now.")
473
+
474
+ return {"response": response_text}
475
+
476
+ except Exception as e:
477
+ logger.error(f"Error in ask endpoint: {str(e)}")
478
+ return {"response": "I encountered an error. Please try again."}
479
 
480
+ # Root endpoint (SAME INTERFACE)
481
+ @router.get("/")
482
+ def root():
483
+ """Root endpoint - SAME INTERFACE with updated info"""
484
+ return {
485
+ "message": "Multi-LLM PhD Advisor Backend is up and running",
486
+ "version": "1.0.0", # Updated version
487
+ "features": [
488
+ "Improved Session Management",
489
+ "Unified Context Handling",
490
+ "Ollama Support",
491
+ "Gemini API Support",
492
+ "Provider Switching"
493
+ ]
494
+ }
multi_llm_chatbot_backend/app/api/routes_old.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from fastapi import APIRouter, Body, HTTPException
3
+ import httpx
4
+ from app.llm.llm_client import LLMClient
5
+ from app.llm.gemini_client import GeminiClient
6
+ from app.llm.short_ollama_client import ShortResponseOllamaClient
7
+ from app.models.persona import Persona
8
+ from app.core.orchestrator import ChatOrchestrator
9
+ from app.core.seamless_orchestrator import SeamlessOrchestrator
10
+ from app.core.context import GlobalSessionContext
11
+ from app.models.default_personas import get_default_personas
12
+ from pydantic import BaseModel
13
+ from typing import Optional, List
14
+ from fastapi import UploadFile, File
15
+ from app.utils.document_extractor import extract_text_from_file
16
+ from app.core.orchestrator import answer_with_persona_context
17
+ from app.utils.chroma_client import add_persona_doc
18
+ import hashlib
19
+ from app.utils.file_limits import is_within_upload_limit
20
+
21
+ router = APIRouter()
22
+
23
+ # Provider management
24
+ current_provider = "gemini"
25
+ available_providers = ["ollama", "gemini"]
26
+
27
+ def create_llm_client(provider: str = None) -> LLMClient:
28
+ """Create LLM client based on provider"""
29
+ if provider is None:
30
+ provider = current_provider
31
+
32
+ if provider == "gemini":
33
+ try:
34
+ return GeminiClient(model_name=os.getenv("GEMINI_MODEL"))
35
+ except ValueError as e:
36
+ # Fallback to Ollama if Gemini API key is not available
37
+ print(f"Gemini API key not found, falling back to Ollama: {e}")
38
+ return ShortResponseOllamaClient(model_name="llama3.2:1b")
39
+ elif provider == "ollama":
40
+ return ShortResponseOllamaClient(model_name="llama3.2:1b")
41
+ else:
42
+ raise ValueError(f"Unknown provider: {provider}")
43
+
44
+ # Initialize with default provider
45
+ llm = create_llm_client()
46
+ chat_orchestrator = ChatOrchestrator()
47
+ seamless_orchestrator = SeamlessOrchestrator(llm=llm)
48
+
49
+ session_context = GlobalSessionContext()
50
+
51
+ # Initialize personas
52
+ DEFAULT_PERSONAS = get_default_personas(llm)
53
+
54
+ for persona in DEFAULT_PERSONAS:
55
+ chat_orchestrator.register_persona(persona)
56
+
57
+ # Data models
58
+ class UserInput(BaseModel):
59
+ user_input: str
60
+
61
+ class PersonaInput(BaseModel):
62
+ id: str
63
+ name: str
64
+ system_prompt: str
65
+
66
+ class ChatMessage(BaseModel):
67
+ user_input: str
68
+ session_id: Optional[str] = None
69
+ response_length: Optional[str] = "medium"
70
+
71
+ class ReplyToAdvisor(BaseModel):
72
+ user_input: str
73
+ advisor_id: str
74
+ original_message_id: Optional[str] = None
75
+
76
+ class ProviderSwitch(BaseModel):
77
+ provider: str
78
+
79
+ # Helper functions for response validation
80
+ def _is_valid_response(response: str, persona_id: str) -> bool:
81
+ """Validate response quality"""
82
+ if len(response) < 2 or len(response) > 5000:
83
+ return False
84
+
85
+ # Check for AI confusion indicators
86
+ confusion_indicators = [
87
+ f"Thank you, Dr. {persona_id.title()}",
88
+ "Assistant:",
89
+ f"Dr. {persona_id.title()} Advisor:",
90
+ "excellent discussion, Assistant"
91
+ ]
92
+
93
+ return not any(indicator in response for indicator in confusion_indicators)
94
+
95
+ def _get_persona_fallback(persona_id: str) -> str:
96
+ """Get persona-specific fallback responses"""
97
+ fallbacks = {
98
+ "methodist": "Focus on ensuring your methodology aligns with your research question. What specific method are you considering?",
99
+ "theorist": "Consider the theoretical framework underlying your approach. What assumptions guide your thinking?",
100
+ "pragmatist": "Let's break this down into actionable steps. What's the most important thing you need to decide today?"
101
+ }
102
+ return fallbacks.get(persona_id, "I'd be happy to help. Could you provide more details?")
103
+
104
+ # Provider management endpoints
105
+ @router.get("/current-provider")
106
+ async def get_current_provider():
107
+ return {
108
+ "current_provider": current_provider,
109
+ "available_providers": available_providers,
110
+ "model_info": {
111
+ "name": llm.model_name if hasattr(llm, 'model_name') else "gemini-2.0-flash",
112
+ "provider": current_provider
113
+ }
114
+ }
115
+
116
+ @router.post("/switch-provider")
117
+ async def switch_provider(provider_data: ProviderSwitch):
118
+ global current_provider, llm
119
+
120
+ if provider_data.provider not in available_providers:
121
+ raise HTTPException(
122
+ status_code=400,
123
+ detail=f"Unknown provider: {provider_data.provider}. Available: {available_providers}"
124
+ )
125
+
126
+ try:
127
+ # Update current provider
128
+ current_provider = provider_data.provider
129
+
130
+ # Create new LLM client
131
+ new_llm = create_llm_client(current_provider)
132
+ llm = new_llm
133
+
134
+ # Update all personas with new LLM
135
+ new_personas = get_default_personas(new_llm)
136
+ chat_orchestrator.personas.clear()
137
+ for persona in new_personas:
138
+ chat_orchestrator.register_persona(persona)
139
+
140
+ # Update seamless orchestrator
141
+ seamless_orchestrator.llm = new_llm
142
+
143
+ return {
144
+ "message": f"Successfully switched to {current_provider}",
145
+ "current_provider": current_provider,
146
+ "model_info": {
147
+ "name": new_llm.model_name if hasattr(new_llm, 'model_name') else "gemini-2.0-flash",
148
+ "provider": current_provider
149
+ }
150
+ }
151
+
152
+ except Exception as e:
153
+ raise HTTPException(
154
+ status_code=500,
155
+ detail=f"Failed to switch to {provider_data.provider}: {str(e)}"
156
+ )
157
+
158
+ # Sequential advisor responses endpoint
159
+ @router.post("/chat-sequential")
160
+ async def chat_sequential(message: ChatMessage):
161
+ """Generate advisor responses with improved quality controls"""
162
+
163
+ try:
164
+ orchestrator_result = await seamless_orchestrator.process_message(message.user_input)
165
+
166
+ if orchestrator_result["status"] == "orchestrator_asking":
167
+ return {
168
+ "type": "orchestrator_question",
169
+ "responses": [{
170
+ "persona": "PhD Advisor Assistant",
171
+ "response": orchestrator_result["orchestrator_question"]
172
+ }],
173
+ "collected_info": orchestrator_result["collected_info"]
174
+ }
175
+
176
+ elif orchestrator_result["status"] == "ready_for_advisors":
177
+ enhanced_context = orchestrator_result["enhanced_context"]
178
+
179
+ # Clear previous advisor responses to avoid confusion
180
+ session_context.clear()
181
+ session_context.append("user", message.user_input)
182
+ session_context.append("orchestrator", enhanced_context)
183
+
184
+ advisor_order = chat_orchestrator.get_response_order()
185
+ print("Advisor Order:")
186
+ print(advisor_order)
187
+ responses = []
188
+
189
+ for persona_id in advisor_order:
190
+ try:
191
+ persona = chat_orchestrator.personas[persona_id]
192
+ reply = await persona.respond(session_context.full_log, response_length="medium")
193
+ print("Replies:")
194
+ print(reply)
195
+
196
+ # Validate response before adding
197
+ if _is_valid_response(reply, persona_id):
198
+ responses.append({
199
+ "persona": persona.name,
200
+ "persona_id": persona_id,
201
+ "response": reply,
202
+ })
203
+ else:
204
+ # Fallback response for invalid responses
205
+ responses.append({
206
+ "persona": persona.name,
207
+ "persona_id": persona_id,
208
+ "response": _get_persona_fallback(persona_id),
209
+ })
210
+
211
+ session_context.append(persona_id, reply)
212
+
213
+ except Exception as e:
214
+ print(f"Error generating response for {persona_id}: {e}")
215
+ responses.append({
216
+ "persona": chat_orchestrator.personas[persona_id].name,
217
+ "persona_id": persona_id,
218
+ "response": _get_persona_fallback(persona_id),
219
+ })
220
+
221
+ print("Response Block: " )
222
+ print(responses)
223
+ return {
224
+ "type": "sequential_responses",
225
+ "responses": responses,
226
+ "collected_info": orchestrator_result["collected_info"]
227
+ }
228
+
229
+ except Exception as e:
230
+ print(f"Error in chat_sequential: {e}")
231
+ return {
232
+ "type": "error",
233
+ "responses": [{
234
+ "persona": "System",
235
+ "response": "I'm having trouble processing your request. Could you please try again?"
236
+ }]
237
+ }
238
+
239
+
240
+ # Individual advisor endpoint with context
241
+ @router.post("/chat/{persona_id}")
242
+ async def chat_with_specific_advisor(persona_id: str, input: UserInput):
243
+ """Chat with a specific advisor"""
244
+ try:
245
+ if persona_id not in chat_orchestrator.personas:
246
+ raise HTTPException(status_code=404, detail=f"Persona '{persona_id}' not found")
247
+
248
+ session_context.append("user", input.user_input)
249
+ persona = chat_orchestrator.personas[persona_id]
250
+ context = session_context.full_log.copy()
251
+ reply = await persona.respond(context, response_length="medium")
252
+ session_context.append(persona_id, reply)
253
+
254
+ return {
255
+ "persona": persona.name,
256
+ "persona_id": persona_id,
257
+ "response": reply
258
+ }
259
+ except HTTPException:
260
+ raise
261
+ except Exception as e:
262
+ print(f"Error in chat_with_specific_advisor: {e}")
263
+ return {
264
+ "persona": "System",
265
+ "response": "I'm having trouble generating a response right now. Please try again."
266
+ }
267
+
268
+ # Reply to specific advisor endpoint
269
+ @router.post("/reply-to-advisor")
270
+ async def reply_to_advisor(reply: ReplyToAdvisor):
271
+ """Reply to a specific advisor and get response only from that advisor"""
272
+
273
+ try:
274
+ if reply.advisor_id not in chat_orchestrator.personas:
275
+ raise HTTPException(status_code=404, detail=f"Advisor '{reply.advisor_id}' not found")
276
+
277
+ # Add user reply to context
278
+ session_context.append("user", reply.user_input)
279
+
280
+ # Get response from specific advisor
281
+ persona = chat_orchestrator.personas[reply.advisor_id]
282
+
283
+ # Generate response
284
+ reply_response = await persona.respond(session_context.full_log, response_length="medium")
285
+ session_context.append(reply.advisor_id, reply_response)
286
+
287
+ return {
288
+ "type": "advisor_reply",
289
+ "persona": persona.name,
290
+ "persona_id": reply.advisor_id,
291
+ "response": reply_response,
292
+ "original_message_id": reply.original_message_id
293
+ }
294
+
295
+ except HTTPException:
296
+ raise
297
+ except Exception as e:
298
+ print(f"Error in reply_to_advisor: {e}")
299
+ return {
300
+ "type": "error",
301
+ "persona": "System",
302
+ "response": "I'm having trouble generating a reply right now. Please try again."
303
+ }
304
+
305
+ # Reset session
306
+ @router.post("/reset-session")
307
+ async def reset_session():
308
+ try:
309
+ seamless_orchestrator.reset()
310
+ session_context.clear()
311
+ return {"status": "reset", "message": "Session reset successfully"}
312
+ except Exception as e:
313
+ print(f"Error resetting session: {e}")
314
+ return {"status": "error", "message": "Failed to reset session"}
315
+
316
+ # Context inspection
317
+ @router.get("/context")
318
+ def get_context():
319
+ return session_context.full_log
320
+
321
+ # Legacy model switching endpoint (now redirects to provider switching)
322
+ @router.post("/switch-model")
323
+ async def switch_model(model_name: str = Body(...)):
324
+ # For backward compatibility, try to map model names to providers
325
+ if "gemini" in model_name.lower():
326
+ return await switch_provider(ProviderSwitch(provider="gemini"))
327
+ else:
328
+ return await switch_provider(ProviderSwitch(provider="ollama"))
329
+
330
+ @router.get("/current-model")
331
+ async def get_current_model():
332
+ # For backward compatibility
333
+ model_name = llm.model_name if hasattr(llm, 'model_name') else "gemini-2.0-flash"
334
+ return {
335
+ "model": model_name,
336
+ "provider": current_provider
337
+ }
338
+
339
+ @router.post("/upload-document")
340
+ async def upload_document(file: UploadFile = File(...)):
341
+ # Validate file type
342
+ if file.content_type not in [
343
+ "application/pdf",
344
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
345
+ "text/plain"
346
+ ]:
347
+ raise HTTPException(status_code=400, detail="Unsupported file type.")
348
+
349
+ try:
350
+ # Read file bytes
351
+ file_bytes = await file.read()
352
+
353
+ # Check file size limit
354
+ if not is_within_upload_limit("default", file_bytes, session_context):
355
+ raise HTTPException(status_code=400, detail="Upload exceeds session document size limit (10 MB).")
356
+
357
+ # Extract and validate text
358
+ content = extract_text_from_file(file_bytes, file.content_type)
359
+ if not content.strip():
360
+ raise HTTPException(status_code=400, detail="Document is empty or unreadable.")
361
+
362
+ # Track file size and name
363
+ session_context.append("Document", f"[Uploaded Document Content]\n{content.strip()}")
364
+ session_context.uploaded_files.append(file.filename)
365
+ session_context.total_upload_size += len(file_bytes)
366
+
367
+ return {"message": "Document uploaded and added to context successfully."}
368
+
369
+ except Exception as e:
370
+ raise HTTPException(status_code=500, detail=f"Error processing document: {str(e)}")
371
+
372
+ # Debug endpoint
373
+ @router.get("/debug/personas")
374
+ async def debug_personas():
375
+ return {
376
+ "personas": {
377
+ pid: {
378
+ "name": persona.name,
379
+ "prompt": persona.system_prompt[:100] + "..."
380
+ } for pid, persona in chat_orchestrator.personas.items()
381
+ },
382
+ "context_length": len(session_context.full_log),
383
+ "current_provider": current_provider
384
+ }
385
+
386
+ class PersonaQuery(BaseModel):
387
+ question: str
388
+ persona: str
389
+
390
+ @router.post("/ask/")
391
+ async def ask_question(query: PersonaQuery):
392
+ response = await answer_with_persona_context(query.question, query.persona)
393
+
394
+ # Store Q&A in vector DB
395
+ combined_text = f"Q: {query.question}\nA: {response}"
396
+ doc_id = hashlib.md5(combined_text.encode()).hexdigest() # Create a unique doc ID
397
+
398
+ add_persona_doc(combined_text, query.persona, doc_id)
399
+
400
+ return {"response": response}
401
+
402
+ @router.get("/uploaded-files")
403
+ def get_uploaded_filenames():
404
+ return {"files": session_context.uploaded_files}
multi_llm_chatbot_backend/app/core/context_manager.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Optional, Tuple
2
+ from dataclasses import dataclass
3
+ import re
4
+ from datetime import datetime
5
+
6
+ @dataclass
7
+ class ContextWindow:
8
+ """Represents a context window for LLM processing"""
9
+ messages: List[dict]
10
+ total_tokens: int
11
+ truncated: bool = False
12
+
13
+ class ContextManager:
14
+ """Unified context management for consistent LLM behavior"""
15
+
16
+ def __init__(self,
17
+ max_context_tokens: int = 8000,
18
+ preserve_recent_messages: int = 5,
19
+ chars_per_token: float = 4.0):
20
+ self.max_context_tokens = max_context_tokens
21
+ self.preserve_recent_messages = preserve_recent_messages
22
+ self.chars_per_token = chars_per_token
23
+
24
+ def prepare_context_for_llm(self,
25
+ messages: List[dict],
26
+ system_prompt: str,
27
+ llm_provider: str = "gemini") -> ContextWindow:
28
+ """
29
+ Prepare context for LLM with intelligent windowing and formatting
30
+ """
31
+ # Calculate token budget
32
+ system_tokens = self._estimate_tokens(system_prompt)
33
+ available_tokens = self.max_context_tokens - system_tokens - 500 # Reserve for response
34
+
35
+ # Get relevant context window
36
+ context_messages = self._get_optimal_context_window(messages, available_tokens)
37
+
38
+ # Format for specific LLM provider
39
+ formatted_messages = self._format_for_provider(context_messages, system_prompt, llm_provider)
40
+
41
+ return ContextWindow(
42
+ messages=formatted_messages,
43
+ total_tokens=self._estimate_tokens_for_messages(formatted_messages),
44
+ truncated=len(context_messages) < len(messages)
45
+ )
46
+
47
+ def _get_optimal_context_window(self, messages: List[dict], token_budget: int) -> List[dict]:
48
+ """
49
+ Select optimal messages for context window using recency + relevance
50
+ """
51
+ if not messages:
52
+ return []
53
+
54
+ # Always preserve the most recent messages
55
+ recent_messages = messages[-self.preserve_recent_messages:]
56
+ recent_tokens = self._estimate_tokens_for_messages(recent_messages)
57
+
58
+ if recent_tokens >= token_budget:
59
+ # If recent messages exceed budget, truncate to fit
60
+ return self._truncate_to_fit(recent_messages, token_budget)
61
+
62
+ # Add older messages if we have token budget remaining
63
+ remaining_budget = token_budget - recent_tokens
64
+ older_messages = messages[:-self.preserve_recent_messages] if len(messages) > self.preserve_recent_messages else []
65
+
66
+ # Score older messages by relevance and recency
67
+ scored_messages = self._score_messages_for_relevance(older_messages, messages[-1]['content'] if messages else "")
68
+
69
+ # Add highest scoring messages that fit in budget
70
+ selected_older = []
71
+ for message, score in scored_messages:
72
+ message_tokens = self._estimate_tokens(message['content'])
73
+ if message_tokens <= remaining_budget:
74
+ selected_older.append(message)
75
+ remaining_budget -= message_tokens
76
+ else:
77
+ break
78
+
79
+ # Combine in chronological order
80
+ return selected_older + recent_messages
81
+
82
+ def _score_messages_for_relevance(self, messages: List[dict], current_query: str) -> List[Tuple[dict, float]]:
83
+ """
84
+ Score messages by relevance to current query and recency
85
+ """
86
+ scored = []
87
+ current_query_lower = current_query.lower()
88
+
89
+ for i, message in enumerate(messages):
90
+ score = 0.0
91
+ content_lower = message['content'].lower()
92
+
93
+ # Recency score (more recent = higher score)
94
+ recency_score = (i + 1) / len(messages) * 0.3
95
+
96
+ # Keyword overlap score
97
+ current_words = set(current_query_lower.split())
98
+ message_words = set(content_lower.split())
99
+ overlap = len(current_words.intersection(message_words))
100
+ keyword_score = min(overlap / max(len(current_words), 1) * 0.4, 0.4)
101
+
102
+ # Role importance (user questions and document content more important)
103
+ role_score = 0.3 if message['role'] in ['user', 'document'] else 0.1
104
+
105
+ score = recency_score + keyword_score + role_score
106
+ scored.append((message, score))
107
+
108
+ # Sort by score descending
109
+ return sorted(scored, key=lambda x: x[1], reverse=True)
110
+
111
+ def _truncate_to_fit(self, messages: List[dict], token_budget: int) -> List[dict]:
112
+ """
113
+ Truncate messages to fit within token budget, preserving most recent
114
+ """
115
+ result = []
116
+ current_tokens = 0
117
+
118
+ # Add messages from most recent backward
119
+ for message in reversed(messages):
120
+ message_tokens = self._estimate_tokens(message['content'])
121
+ if current_tokens + message_tokens <= token_budget:
122
+ result.insert(0, message)
123
+ current_tokens += message_tokens
124
+ else:
125
+ break
126
+
127
+ return result
128
+
129
+ def _format_for_provider(self, messages: List[dict], system_prompt: str, provider: str) -> List[dict]:
130
+ """
131
+ Format messages for specific LLM provider
132
+ """
133
+ if provider.lower() == "gemini":
134
+ return self._format_for_gemini(messages, system_prompt)
135
+ elif provider.lower() in ["ollama", "mistral"]:
136
+ return self._format_for_ollama(messages, system_prompt)
137
+ else:
138
+ # Default format
139
+ return [{"role": "system", "content": system_prompt}] + messages
140
+
141
+ def _format_for_gemini(self, messages: List[dict], system_prompt: str) -> List[dict]:
142
+ """
143
+ Format messages for Gemini API (uses user/model roles with parts structure)
144
+ """
145
+ formatted = []
146
+
147
+ # Add system prompt as initial exchange
148
+ if system_prompt:
149
+ formatted.extend([
150
+ {
151
+ "role": "user",
152
+ "parts": [{"text": system_prompt}]
153
+ },
154
+ {
155
+ "role": "model",
156
+ "parts": [{"text": "I understand. I'll follow these instructions."}]
157
+ }
158
+ ])
159
+
160
+ # Convert messages to Gemini format
161
+ for message in messages:
162
+ role = message['role']
163
+ content = message['content']
164
+
165
+ if role == 'user':
166
+ formatted.append({
167
+ "role": "user",
168
+ "parts": [{"text": content}]
169
+ })
170
+ elif role in ['assistant', 'methodist', 'theorist', 'pragmatist']:
171
+ formatted.append({
172
+ "role": "model",
173
+ "parts": [{"text": content}]
174
+ })
175
+ elif role == 'document':
176
+ # Add document as user context
177
+ formatted.append({
178
+ "role": "user",
179
+ "parts": [{"text": f"[Context Document] {content}"}]
180
+ })
181
+
182
+ return formatted
183
+
184
+ def _format_for_ollama(self, messages: List[dict], system_prompt: str) -> str:
185
+ """
186
+ Format messages for Ollama (returns formatted prompt string)
187
+ """
188
+ parts = [system_prompt] if system_prompt else []
189
+
190
+ for message in messages:
191
+ role = message['role'].capitalize()
192
+ content = message['content']
193
+
194
+ if role == 'Document':
195
+ parts.append(f"Context: {content}")
196
+ else:
197
+ parts.append(f"{role}: {content}")
198
+
199
+ parts.append("Assistant:")
200
+ return "\n\n".join(parts)
201
+
202
+ def _estimate_tokens(self, text: str) -> int:
203
+ """
204
+ Estimate token count for text
205
+ """
206
+ return int(len(text) / self.chars_per_token)
207
+
208
+ def _estimate_tokens_for_messages(self, messages: List[dict]) -> int:
209
+ """
210
+ Estimate total tokens for list of messages
211
+ """
212
+ if isinstance(messages, str):
213
+ return self._estimate_tokens(messages)
214
+
215
+ total = 0
216
+ for message in messages:
217
+ if isinstance(message, dict):
218
+ if 'content' in message:
219
+ total += self._estimate_tokens(message['content'])
220
+ elif 'parts' in message:
221
+ # Gemini format
222
+ for part in message['parts']:
223
+ if 'text' in part:
224
+ total += self._estimate_tokens(part['text'])
225
+ else:
226
+ total += self._estimate_tokens(str(message))
227
+
228
+ return total
229
+
230
+ def get_context_summary(self, messages: List[dict]) -> Dict[str, any]:
231
+ """
232
+ Get summary information about context
233
+ """
234
+ if not messages:
235
+ return {"total_messages": 0, "estimated_tokens": 0, "roles": {}}
236
+
237
+ role_counts = {}
238
+ for message in messages:
239
+ role = message.get('role', 'unknown')
240
+ role_counts[role] = role_counts.get(role, 0) + 1
241
+
242
+ return {
243
+ "total_messages": len(messages),
244
+ "estimated_tokens": self._estimate_tokens_for_messages(messages),
245
+ "roles": role_counts,
246
+ "oldest_message": messages[0].get('timestamp', 'unknown') if messages else None,
247
+ "newest_message": messages[-1].get('timestamp', 'unknown') if messages else None
248
+ }
249
+
250
+ # Global context manager instance
251
+ context_manager = ContextManager()
252
+
253
+ def get_context_manager() -> ContextManager:
254
+ """Get the global context manager instance"""
255
+ return context_manager
multi_llm_chatbot_backend/app/core/improved_orchestrator.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List, Optional, Any
2
+ from app.models.persona import Persona
3
+ from app.core.session_manager import ConversationContext, get_session_manager
4
+ from app.core.context_manager import get_context_manager
5
+ from app.llm.llm_client import LLMClient
6
+ import logging
7
+ import re
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class ImprovedChatOrchestrator:
12
+ """
13
+ Improved orchestrator with proper session management and context handling
14
+ """
15
+
16
+ def __init__(self):
17
+ self.personas: Dict[str, Persona] = {}
18
+ self.session_manager = get_session_manager()
19
+ self.context_manager = get_context_manager()
20
+
21
+ def register_persona(self, persona: Persona):
22
+ """Register a persona with the orchestrator"""
23
+ self.personas[persona.id] = persona
24
+ logger.info(f"Registered persona: {persona.id} ({persona.name})")
25
+
26
+ def get_persona(self, persona_id: str) -> Optional[Persona]:
27
+ """Get a specific persona"""
28
+ return self.personas.get(persona_id)
29
+
30
+ def list_personas(self) -> List[str]:
31
+ """List all available persona IDs"""
32
+ return list(self.personas.keys())
33
+
34
+ async def process_message(self,
35
+ user_input: str,
36
+ session_id: Optional[str] = None,
37
+ response_length: str = "medium") -> Dict[str, Any]:
38
+ """
39
+ Process a user message through the orchestration pipeline
40
+ """
41
+ try:
42
+ # Get or create session
43
+ session = self.session_manager.get_session(session_id)
44
+
45
+ # Add user message to session
46
+ session.append_message("user", user_input)
47
+
48
+ # Determine if we need clarification
49
+ needs_clarification = self._needs_clarification(session, user_input)
50
+
51
+ if needs_clarification:
52
+ # Generate clarification question
53
+ clarification = await self._generate_clarification_question(session)
54
+ session.append_message("orchestrator", clarification)
55
+
56
+ return {
57
+ "type": "clarification",
58
+ "session_id": session.session_id,
59
+ "message": clarification,
60
+ "context_summary": self.context_manager.get_context_summary(session.messages)
61
+ }
62
+
63
+ # Generate responses from all personas
64
+ responses = await self._generate_persona_responses(session, response_length)
65
+
66
+ return {
67
+ "type": "persona_responses",
68
+ "session_id": session.session_id,
69
+ "responses": responses,
70
+ "context_summary": self.context_manager.get_context_summary(session.messages)
71
+ }
72
+
73
+ except Exception as e:
74
+ logger.error(f"Error in process_message: {str(e)}")
75
+ return {
76
+ "type": "error",
77
+ "session_id": session_id,
78
+ "message": "I encountered an error processing your request. Please try again.",
79
+ "error": str(e)
80
+ }
81
+
82
+ async def chat_with_persona(self,
83
+ user_input: str,
84
+ persona_id: str,
85
+ session_id: Optional[str] = None,
86
+ response_length: str = "medium") -> Dict[str, Any]:
87
+ """
88
+ Chat with a specific persona
89
+ """
90
+ try:
91
+ if persona_id not in self.personas:
92
+ return {
93
+ "type": "error",
94
+ "message": f"Persona '{persona_id}' not found"
95
+ }
96
+
97
+ # Get or create session
98
+ session = self.session_manager.get_session(session_id)
99
+
100
+ # Add user message
101
+ session.append_message("user", user_input)
102
+
103
+ # Generate response from specific persona
104
+ persona = self.personas[persona_id]
105
+ response = await self._generate_single_persona_response(session, persona, response_length)
106
+
107
+ # Add persona response to session
108
+ session.append_message(persona_id, response['response'])
109
+
110
+ return {
111
+ "type": "single_persona_response",
112
+ "session_id": session.session_id,
113
+ "persona": response,
114
+ "context_summary": self.context_manager.get_context_summary(session.messages)
115
+ }
116
+
117
+ except Exception as e:
118
+ logger.error(f"Error in chat_with_persona: {str(e)}")
119
+ return {
120
+ "type": "error",
121
+ "session_id": session_id,
122
+ "message": "I encountered an error generating a response. Please try again.",
123
+ "error": str(e)
124
+ }
125
+
126
+ def _needs_clarification(self, session: ConversationContext, user_input: str) -> bool:
127
+ """
128
+ Determine if user input needs clarification
129
+ """
130
+ # Don't ask for clarification if this is a follow-up message
131
+ user_messages = session.get_messages_by_role("user")
132
+ if len(user_messages) > 1:
133
+ return False
134
+
135
+ # Check if input is vague
136
+ vague_patterns = [
137
+ r"i'm (not sure|unsure|confused|lost)",
138
+ r"i (don't know|dunno) (what|how|where)",
139
+ r"help me with (my|the|a) (thesis|research|phd)",
140
+ r"(what should i|how do i|where do i start)",
141
+ r"i need (help|advice|guidance)$",
142
+ r"(stuck|struggling) with",
143
+ r"(any|some) (advice|suggestions|ideas)$",
144
+ r"^(help|advice|guidance)$",
145
+ ]
146
+
147
+ user_lower = user_input.lower().strip()
148
+
149
+ for pattern in vague_patterns:
150
+ if re.search(pattern, user_lower):
151
+ return True
152
+
153
+ # Check if input is too short (likely vague)
154
+ if len(user_input.split()) < 8:
155
+ return True
156
+
157
+ return False
158
+
159
+ async def _generate_clarification_question(self, session: ConversationContext) -> str:
160
+ """
161
+ Generate a clarification question based on context
162
+ """
163
+ user_messages = session.get_messages_by_role("user")
164
+ if not user_messages:
165
+ return "What specific aspect of your PhD journey would you like guidance on?"
166
+
167
+ latest_message = user_messages[-1]['content']
168
+
169
+ # Extract what information we might be missing
170
+ missing_info = []
171
+
172
+ # Check for research area
173
+ research_keywords = ["computer science", "biology", "psychology", "physics", "chemistry",
174
+ "engineering", "literature", "history", "mathematics", "sociology"]
175
+ if not any(keyword in latest_message.lower() for keyword in research_keywords):
176
+ missing_info.append("research area")
177
+
178
+ # Check for specific question type
179
+ question_keywords = ["methodology", "theory", "writing", "analysis", "data", "literature review"]
180
+ if not any(keyword in latest_message.lower() for keyword in question_keywords):
181
+ missing_info.append("specific aspect")
182
+
183
+ # Check for academic stage
184
+ stage_keywords = ["first year", "second year", "third year", "qualifying", "dissertation",
185
+ "defense", "proposal", "coursework"]
186
+ if not any(keyword in latest_message.lower() for keyword in stage_keywords):
187
+ missing_info.append("PhD stage")
188
+
189
+ # Generate appropriate clarification question
190
+ if "research area" in missing_info:
191
+ return "What field or discipline are you studying in?"
192
+ elif "specific aspect" in missing_info:
193
+ return "What specific aspect of your research would you like guidance on?"
194
+ elif "PhD stage" in missing_info:
195
+ return "What stage of your PhD program are you currently in?"
196
+ else:
197
+ return "Could you provide more details about your specific situation or question?"
198
+
199
+ async def _generate_persona_responses(self,
200
+ session: ConversationContext,
201
+ response_length: str) -> List[Dict[str, Any]]:
202
+ """
203
+ Generate responses from all personas
204
+ """
205
+ responses = []
206
+
207
+ # Get the conversation context for personas
208
+ context_messages = session.get_recent_messages(limit=20)
209
+
210
+ for persona_id, persona in self.personas.items():
211
+ try:
212
+ response_data = await self._generate_single_persona_response(
213
+ session, persona, response_length
214
+ )
215
+ responses.append(response_data)
216
+
217
+ # Add persona response to session
218
+ session.append_message(persona_id, response_data['response'])
219
+
220
+ except Exception as e:
221
+ logger.error(f"Error generating response for persona {persona_id}: {str(e)}")
222
+ # Add fallback response
223
+ responses.append({
224
+ "persona_id": persona_id,
225
+ "persona_name": persona.name,
226
+ "response": self._get_fallback_response(persona_id),
227
+ "error": True
228
+ })
229
+
230
+ return responses
231
+
232
+ async def _generate_single_persona_response(self,
233
+ session: ConversationContext,
234
+ persona: Persona,
235
+ response_length: str) -> Dict[str, Any]:
236
+ """
237
+ Generate response from a single persona
238
+ """
239
+ try:
240
+ # Get conversation context
241
+ context_messages = session.get_recent_messages(limit=20)
242
+
243
+ # Generate response using persona
244
+ response = await persona.respond(context_messages, response_length)
245
+
246
+ # Validate response
247
+ if not self._is_valid_response(response, persona.id):
248
+ response = self._get_fallback_response(persona.id)
249
+
250
+ return {
251
+ "persona_id": persona.id,
252
+ "persona_name": persona.name,
253
+ "response": response,
254
+ "error": False
255
+ }
256
+
257
+ except Exception as e:
258
+ logger.error(f"Error in _generate_single_persona_response for {persona.id}: {str(e)}")
259
+ return {
260
+ "persona_id": persona.id,
261
+ "persona_name": persona.name,
262
+ "response": self._get_fallback_response(persona.id),
263
+ "error": True
264
+ }
265
+
266
+ def _is_valid_response(self, response: str, persona_id: str) -> bool:
267
+ """Validate response quality"""
268
+ if not response or len(response.strip()) < 10:
269
+ return False
270
+
271
+ if len(response) > 2000: # Too long
272
+ return False
273
+
274
+ # Check for AI confusion indicators
275
+ confusion_indicators = [
276
+ f"Thank you, Dr. {persona_id.title()}",
277
+ "Assistant:",
278
+ f"Dr. {persona_id.title()}:",
279
+ "excellent discussion, Assistant"
280
+ ]
281
+
282
+ return not any(indicator in response for indicator in confusion_indicators)
283
+
284
+ def _get_fallback_response(self, persona_id: str) -> str:
285
+ """Get persona-specific fallback response"""
286
+ fallbacks = {
287
+ "methodist": "Let's focus on your research methodology. What specific methodological approach are you considering?",
288
+ "theorist": "I'd like to explore the theoretical foundation of your work. What conceptual framework guides your research?",
289
+ "pragmatist": "Let's take a practical approach. What's the most pressing decision you need to make about your research right now?"
290
+ }
291
+ return fallbacks.get(persona_id, "I'd be happy to help. Could you provide more specific details about your question?")
292
+
293
+ def get_session_info(self, session_id: str) -> Optional[Dict[str, Any]]:
294
+ """Get information about a session"""
295
+ session = self.session_manager.get_session(session_id)
296
+ if session:
297
+ return {
298
+ "session_id": session.session_id,
299
+ "message_count": len(session.messages),
300
+ "uploaded_files": session.uploaded_files,
301
+ "created_at": session.created_at.isoformat(),
302
+ "last_accessed": session.last_accessed.isoformat(),
303
+ "context_summary": self.context_manager.get_context_summary(session.messages)
304
+ }
305
+ return None
306
+
307
+ def reset_session(self, session_id: str) -> bool:
308
+ """Reset a session (clear messages but keep metadata)"""
309
+ session = self.session_manager.get_session(session_id)
310
+ if session:
311
+ session.clear_messages()
312
+ return True
313
+ return False
314
+
315
+ def delete_session(self, session_id: str) -> bool:
316
+ """Delete a session completely"""
317
+ return self.session_manager.delete_session(session_id)
multi_llm_chatbot_backend/app/core/session_manager.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List, Optional, Any
2
+ from datetime import datetime, timedelta
3
+ import uuid
4
+ from dataclasses import dataclass, field
5
+ import asyncio
6
+ from threading import Lock
7
+
8
+ @dataclass
9
+ class ConversationContext:
10
+ """Individual conversation context for a session"""
11
+ session_id: str
12
+ messages: List[dict] = field(default_factory=list)
13
+ uploaded_files: List[str] = field(default_factory=list)
14
+ total_upload_size: int = 0
15
+ metadata: Dict[str, Any] = field(default_factory=dict)
16
+ created_at: datetime = field(default_factory=datetime.now)
17
+ last_accessed: datetime = field(default_factory=datetime.now)
18
+
19
+ def append_message(self, role: str, content: str):
20
+ """Add a message to the conversation"""
21
+ self.messages.append({
22
+ "role": role,
23
+ "content": content,
24
+ "timestamp": datetime.now().isoformat()
25
+ })
26
+ self.last_accessed = datetime.now()
27
+
28
+ def get_recent_messages(self, limit: int = 20) -> List[dict]:
29
+ """Get recent messages with limit"""
30
+ return self.messages[-limit:] if len(self.messages) > limit else self.messages
31
+
32
+ def get_messages_by_role(self, role: str) -> List[dict]:
33
+ """Get all messages from a specific role"""
34
+ return [msg for msg in self.messages if msg['role'] == role]
35
+
36
+ def clear_messages(self):
37
+ """Clear all messages but preserve metadata"""
38
+ self.messages = []
39
+ self.last_accessed = datetime.now()
40
+
41
+ def add_uploaded_file(self, filename: str, content: str, file_size: int):
42
+ """Add uploaded file content to context"""
43
+ self.uploaded_files.append(filename)
44
+ self.total_upload_size += file_size
45
+ self.append_message("document", f"[Uploaded: {filename}]\n{content}")
46
+
47
+ def get_context_size(self) -> int:
48
+ """Calculate total context size in characters"""
49
+ return sum(len(msg['content']) for msg in self.messages)
50
+
51
+ class SessionManager:
52
+ """Thread-safe session manager for handling multiple user conversations"""
53
+
54
+ def __init__(self, session_timeout_hours: int = 24, cleanup_interval_minutes: int = 60):
55
+ self.sessions: Dict[str, ConversationContext] = {}
56
+ self.session_timeout = timedelta(hours=session_timeout_hours)
57
+ self.cleanup_interval = timedelta(minutes=cleanup_interval_minutes)
58
+ self.lock = Lock()
59
+ self.last_cleanup = datetime.now()
60
+
61
+ def create_session(self) -> str:
62
+ """Create a new session and return session ID"""
63
+ session_id = str(uuid.uuid4())
64
+ with self.lock:
65
+ self.sessions[session_id] = ConversationContext(session_id=session_id)
66
+ return session_id
67
+
68
+ def get_session(self, session_id: Optional[str] = None) -> ConversationContext:
69
+ """Get existing session or create new one"""
70
+ if not session_id:
71
+ session_id = self.create_session()
72
+
73
+ with self.lock:
74
+ if session_id not in self.sessions:
75
+ self.sessions[session_id] = ConversationContext(session_id=session_id)
76
+
77
+ session = self.sessions[session_id]
78
+ session.last_accessed = datetime.now()
79
+
80
+ # Trigger cleanup if needed
81
+ self._cleanup_expired_sessions()
82
+
83
+ return session
84
+
85
+ def delete_session(self, session_id: str) -> bool:
86
+ """Delete a specific session"""
87
+ with self.lock:
88
+ if session_id in self.sessions:
89
+ del self.sessions[session_id]
90
+ return True
91
+ return False
92
+
93
+ def get_active_session_count(self) -> int:
94
+ """Get number of active sessions"""
95
+ with self.lock:
96
+ return len(self.sessions)
97
+
98
+ def _cleanup_expired_sessions(self):
99
+ """Remove expired sessions (called periodically)"""
100
+ now = datetime.now()
101
+
102
+ # Only run cleanup periodically to avoid overhead
103
+ if now - self.last_cleanup < self.cleanup_interval:
104
+ return
105
+
106
+ expired_sessions = []
107
+ for session_id, session in self.sessions.items():
108
+ if now - session.last_accessed > self.session_timeout:
109
+ expired_sessions.append(session_id)
110
+
111
+ for session_id in expired_sessions:
112
+ del self.sessions[session_id]
113
+
114
+ self.last_cleanup = now
115
+
116
+ if expired_sessions:
117
+ print(f"Cleaned up {len(expired_sessions)} expired sessions")
118
+
119
+ # Global session manager instance
120
+ session_manager = SessionManager()
121
+
122
+ def get_session_manager() -> SessionManager:
123
+ """Get the global session manager instance"""
124
+ return session_manager
multi_llm_chatbot_backend/app/llm/improved_gemini_client.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import os
3
+ from typing import List
4
+ from app.llm.llm_client import LLMClient
5
+ from app.core.context_manager import get_context_manager
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class ImprovedGeminiClient(LLMClient):
11
+ def __init__(self, model_name: str = None):
12
+ if model_name is None:
13
+ model_name = os.getenv("GEMINI_MODEL", "gemini-2.0-flash-exp")
14
+
15
+ self.model_name = model_name
16
+ self.api_key = os.getenv("GEMINI_API_KEY")
17
+ if not self.api_key:
18
+ raise ValueError("GEMINI_API_KEY environment variable is required")
19
+
20
+ self.base_url = "https://generativelanguage.googleapis.com/v1beta/models"
21
+ self.context_manager = get_context_manager()
22
+
23
+ async def generate(self, system_prompt: str, context: List[dict], temperature: float, max_tokens: int) -> str:
24
+ """
25
+ Generate response using improved context management
26
+ """
27
+ try:
28
+ # Use context manager to prepare optimal context window
29
+ context_window = self.context_manager.prepare_context_for_llm(
30
+ messages=context,
31
+ system_prompt=system_prompt,
32
+ llm_provider="gemini"
33
+ )
34
+
35
+ logger.debug(f"Context prepared: {len(context_window.messages)} messages, "
36
+ f"~{context_window.total_tokens} tokens, truncated={context_window.truncated}")
37
+
38
+ payload = {
39
+ "contents": context_window.messages,
40
+ "generationConfig": {
41
+ "temperature": temperature,
42
+ "topK": 40,
43
+ "topP": 0.9,
44
+ "maxOutputTokens": max_tokens,
45
+ "stopSequences": ["Student:", "Question:", "\n\nStudent:", "\n\nQuestion:"]
46
+ },
47
+ "safetySettings": [
48
+ {
49
+ "category": "HARM_CATEGORY_HARASSMENT",
50
+ "threshold": "BLOCK_MEDIUM_AND_ABOVE"
51
+ },
52
+ {
53
+ "category": "HARM_CATEGORY_HATE_SPEECH",
54
+ "threshold": "BLOCK_MEDIUM_AND_ABOVE"
55
+ },
56
+ {
57
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
58
+ "threshold": "BLOCK_MEDIUM_AND_ABOVE"
59
+ },
60
+ {
61
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
62
+ "threshold": "BLOCK_MEDIUM_AND_ABOVE"
63
+ }
64
+ ]
65
+ }
66
+
67
+ url = f"{self.base_url}/{self.model_name}:generateContent?key={self.api_key}"
68
+
69
+ async with httpx.AsyncClient(timeout=30.0) as client:
70
+ response = await client.post(url, json=payload)
71
+ response.raise_for_status()
72
+
73
+ result = response.json()
74
+
75
+ if "candidates" in result and result["candidates"]:
76
+ candidate = result["candidates"][0]
77
+ if "content" in candidate and "parts" in candidate["content"]:
78
+ text = candidate["content"]["parts"][0].get("text", "")
79
+ return self._clean_response(text)
80
+
81
+ # Handle safety filter or other issues
82
+ if "promptFeedback" in result:
83
+ feedback = result["promptFeedback"]
84
+ logger.warning(f"Gemini prompt feedback: {feedback}")
85
+
86
+ return "I apologize, but I'm unable to provide a response to that query."
87
+
88
+ except httpx.TimeoutException:
89
+ logger.error("Gemini API timeout")
90
+ return "I'm experiencing a delay in processing. Please try again."
91
+ except httpx.HTTPStatusError as e:
92
+ logger.error(f"Gemini API HTTP error: {e.response.status_code} - {e.response.text}")
93
+ return "I'm having trouble accessing the AI service. Please try again."
94
+ except Exception as e:
95
+ logger.error(f"Unexpected error in Gemini client: {str(e)}")
96
+ return "I encountered an unexpected error. Please try again."
97
+
98
+ def _clean_response(self, response: str) -> str:
99
+ """Clean up response text"""
100
+ # Remove common issues
101
+ response = response.strip()
102
+
103
+ # Remove duplicate spaces and normalize
104
+ response = ' '.join(response.split())
105
+
106
+ return response
multi_llm_chatbot_backend/app/llm/improved_ollama_client.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ from typing import List
3
+ from app.llm.llm_client import LLMClient
4
+ from app.core.context_manager import get_context_manager
5
+ import logging
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class ImprovedOllamaClient(LLMClient):
10
+ def __init__(self, model_name: str = "llama3.2:1b", base_url: str = "http://localhost:11434"):
11
+ self.model_name = model_name
12
+ self.base_url = base_url
13
+ self.context_manager = get_context_manager()
14
+
15
+ async def generate(self, system_prompt: str, context: List[dict], temperature: float, max_tokens: int) -> str:
16
+ """
17
+ Generate response using improved context management
18
+ """
19
+ try:
20
+ # Use context manager to prepare optimal context
21
+ context_window = self.context_manager.prepare_context_for_llm(
22
+ messages=context,
23
+ system_prompt=system_prompt,
24
+ llm_provider="ollama"
25
+ )
26
+
27
+ logger.debug(f"Context prepared: ~{context_window.total_tokens} tokens, "
28
+ f"truncated={context_window.truncated}")
29
+
30
+ # For Ollama, context_window.messages is a formatted prompt string
31
+ formatted_prompt = context_window.messages
32
+
33
+ payload = {
34
+ "model": self.model_name,
35
+ "prompt": formatted_prompt,
36
+ "stream": False,
37
+ "options": {
38
+ "temperature": temperature,
39
+ "top_p": 0.9,
40
+ "top_k": 40,
41
+ "num_predict": max_tokens,
42
+ "repeat_penalty": 1.1,
43
+ "stop": ["\n\nStudent:", "\n\nUser:", "Question:", "Student:"]
44
+ }
45
+ }
46
+
47
+ async with httpx.AsyncClient(timeout=30.0) as client:
48
+ response = await client.post(f"{self.base_url}/api/generate", json=payload)
49
+ response.raise_for_status()
50
+
51
+ result = response.json()
52
+ text = result.get("response", "").strip()
53
+
54
+ return self._clean_response(text)
55
+
56
+ except httpx.ConnectError:
57
+ logger.error(f"Cannot connect to Ollama at {self.base_url}")
58
+ return "I'm unable to connect to the local AI service. Please ensure Ollama is running."
59
+ except httpx.TimeoutException:
60
+ logger.error("Ollama request timeout")
61
+ return "The AI service is taking too long to respond. Please try again."
62
+ except httpx.HTTPStatusError as e:
63
+ logger.error(f"Ollama HTTP error: {e.response.status_code}")
64
+ return "The AI service encountered an error. Please try again."
65
+ except Exception as e:
66
+ logger.error(f"Unexpected error in Ollama client: {str(e)}")
67
+ return "I encountered an unexpected error. Please try again."
68
+
69
+ def _clean_response(self, response: str) -> str:
70
+ """Clean up common response issues"""
71
+ # Remove common prefixes that indicate AI confusion
72
+ prefixes_to_remove = [
73
+ "Here are 2-3 sentence", "Here's an expansion", "Assistant:",
74
+ "Dr. Methodist:", "Dr. Theorist:", "Dr. Pragmatist:",
75
+ "Methodist Advisor:", "Theorist Advisor:", "Pragmatist Advisor:",
76
+ ]
77
+
78
+ for prefix in prefixes_to_remove:
79
+ if response.startswith(prefix):
80
+ response = response[len(prefix):].strip()
81
+
82
+ # Remove trailing incomplete sentences
83
+ sentences = response.split('.')
84
+ if len(sentences) > 1 and len(sentences[-1].strip()) < 10:
85
+ response = '.'.join(sentences[:-1]) + '.'
86
+
87
+ # Remove excessive academic fluff
88
+ fluff_patterns = [
89
+ "conceptual insights:", "actionable advice:", "my inquisitive student",
90
+ "excellent question", "thank you for", "assistant!"
91
+ ]
92
+
93
+ for pattern in fluff_patterns:
94
+ response = response.replace(pattern, "").strip()
95
+
96
+ # Normalize whitespace
97
+ response = ' '.join(response.split())
98
+
99
+ return response
100
+
101
+ def _is_poor_quality(self, response: str) -> bool:
102
+ """Check if response quality is poor"""
103
+ poor_indicators = [
104
+ "Thank you, Dr." in response,
105
+ "Assistant:" in response,
106
+ len(response.split()) > 150, # Too verbose
107
+ response.count("?") > 3, # Too many questions
108
+ ]
109
+ return any(poor_indicators)
multi_llm_chatbot_backend/app/main.py CHANGED
@@ -1,34 +1,42 @@
1
- # app/main.py
2
  from fastapi import FastAPI
3
  from fastapi.middleware.cors import CORSMiddleware
4
- from app.api.routes import router
5
  from dotenv import load_dotenv
 
6
  import os
7
 
8
- # Load environment variables
 
 
 
 
9
  load_dotenv()
10
 
11
  app = FastAPI(
12
  title="Multi-LLM Chatbot Backend",
13
- version="0.2"
14
  )
15
 
16
- # Add CORS middleware
17
  app.add_middleware(
18
  CORSMiddleware,
19
- allow_origins=["http://localhost:3000"], # React dev server
20
  allow_credentials=True,
21
  allow_methods=["*"],
22
  allow_headers=["*"],
23
  )
24
 
25
- # Include route definitions
26
  app.include_router(router)
27
 
28
  @app.get("/")
29
  def root():
30
  return {
31
  "message": "Multi-LLM PhD Advisor Backend is up and running",
32
- "version": "0.2",
33
- "features": ["Ollama Support", "Gemini API Support", "Provider Switching"]
 
 
 
 
 
 
34
  }
 
 
1
  from fastapi import FastAPI
2
  from fastapi.middleware.cors import CORSMiddleware
3
+ from app.api.routes import router # This line stays the same!
4
  from dotenv import load_dotenv
5
+ import logging
6
  import os
7
 
8
+ logging.basicConfig(
9
+ level=logging.INFO,
10
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
11
+ )
12
+
13
  load_dotenv()
14
 
15
  app = FastAPI(
16
  title="Multi-LLM Chatbot Backend",
17
+ version="1.0.0" # Updated version
18
  )
19
 
 
20
  app.add_middleware(
21
  CORSMiddleware,
22
+ allow_origins=["http://localhost:3000"],
23
  allow_credentials=True,
24
  allow_methods=["*"],
25
  allow_headers=["*"],
26
  )
27
 
 
28
  app.include_router(router)
29
 
30
  @app.get("/")
31
  def root():
32
  return {
33
  "message": "Multi-LLM PhD Advisor Backend is up and running",
34
+ "version": "1.0.0",
35
+ "features": [
36
+ "Improved Session Management",
37
+ "Unified Context Handling",
38
+ "Ollama Support",
39
+ "Gemini API Support",
40
+ "Provider Switching"
41
+ ]
42
  }