dylanglenister commited on
Commit
01327bf
·
2 Parent(s): 169a42b cf9807d

Merge branch 'main' of hf.co:spaces/MedAI-COS30018/MedicalDiagnosisSystem into space

Browse files
Files changed (6) hide show
  1. app.py +20 -1
  2. memo/history.py +4 -5
  3. static/app.js +139 -12
  4. static/index.html +20 -0
  5. static/styles.css +55 -0
  6. utils/naming.py +76 -0
app.py CHANGED
@@ -23,11 +23,12 @@ except Exception as e:
23
  print(f"⚠️ Error loading .env file: {e}")
24
 
25
  from memo.history import MedicalHistoryManager
26
- from utils.medical_kb import search_medical_kb
27
  # Import our custom modules
28
  from memo.memory import MemoryLRU
29
  from utils.embeddings import create_embedding_client
30
  from utils.logger import get_logger
 
 
31
  from utils.rotator import APIKeyRotator
32
 
33
  # Configure logging
@@ -111,6 +112,10 @@ async def lifespan(app: FastAPI):
111
  # Shutdown code here
112
  shutdown_event()
113
 
 
 
 
 
114
  # Initialize FastAPI app
115
  app = FastAPI(
116
  lifespan=lifespan,
@@ -276,6 +281,10 @@ def generate_medical_response(user_message: str, user_role: str, user_specialty:
276
  """Legacy function - now calls the fallback generator"""
277
  return generate_medical_response_fallback(user_message, user_role, user_specialty, medical_context)
278
 
 
 
 
 
279
  @app.get("/", response_class=HTMLResponse)
280
  async def get_medical_chatbot():
281
  """Serve the medical chatbot UI"""
@@ -493,6 +502,16 @@ async def get_api_info():
493
  ]
494
  }
495
 
 
 
 
 
 
 
 
 
 
 
496
  # Mount static files
497
  app.mount("/static", StaticFiles(directory="static"), name="static")
498
 
 
23
  print(f"⚠️ Error loading .env file: {e}")
24
 
25
  from memo.history import MedicalHistoryManager
 
26
  # Import our custom modules
27
  from memo.memory import MemoryLRU
28
  from utils.embeddings import create_embedding_client
29
  from utils.logger import get_logger
30
+ from utils.medical_kb import search_medical_kb
31
+ from utils.naming import summarize_title as nvidia_summarize_title
32
  from utils.rotator import APIKeyRotator
33
 
34
  # Configure logging
 
112
  # Shutdown code here
113
  shutdown_event()
114
 
115
+ class SummarizeRequest(BaseModel):
116
+ text: str
117
+ max_words: int | None = 5
118
+
119
  # Initialize FastAPI app
120
  app = FastAPI(
121
  lifespan=lifespan,
 
281
  """Legacy function - now calls the fallback generator"""
282
  return generate_medical_response_fallback(user_message, user_role, user_specialty, medical_context)
283
 
284
+ async def summarize_title_with_nvidia(text: str, nvidia_rotator: APIKeyRotator, max_words: int = 5) -> str:
285
+ """Use NVIDIA API via utils.naming with rotator. Includes internal fallback."""
286
+ return await nvidia_summarize_title(text, nvidia_rotator, max_words)
287
+
288
  @app.get("/", response_class=HTMLResponse)
289
  async def get_medical_chatbot():
290
  """Serve the medical chatbot UI"""
 
502
  ]
503
  }
504
 
505
+ @app.post("/summarize")
506
+ async def summarize_endpoint(req: SummarizeRequest):
507
+ """Summarize a text into a short 3-5 word title using NVIDIA if available."""
508
+ try:
509
+ title = await summarize_title_with_nvidia(req.text, nvidia_rotator, max_words=min(max(req.max_words or 5, 3), 7))
510
+ return {"title": title}
511
+ except Exception as e:
512
+ logger.error(f"Error summarizing title: {e}")
513
+ raise HTTPException(status_code=500, detail=str(e))
514
+
515
  # Mount static files
516
  app.mount("/static", StaticFiles(directory="static"), name="static")
517
 
memo/history.py CHANGED
@@ -44,7 +44,7 @@ async def summarize_qa_with_gemini(question: str, answer: str, rotator) -> str:
44
  """
45
  try:
46
  # Import Gemini client
47
- import google.generativeai as genai
48
 
49
  # Get API key from rotator
50
  api_key = rotator.get_key()
@@ -53,8 +53,7 @@ async def summarize_qa_with_gemini(question: str, answer: str, rotator) -> str:
53
  return f"q: {question.strip()[:160]}\na: {answer.strip()[:220]}"
54
 
55
  # Configure Gemini
56
- genai.configure(api_key=api_key)
57
- model = genai.GenerativeModel('gemini-1.5-flash')
58
 
59
  # Create prompt for summarization
60
  prompt = f"""You are a medical summarizer. Create a concise summary of this Q&A exchange.
@@ -70,7 +69,7 @@ a: <brief answer summary>
70
  Keep each summary under 160 characters for question and 220 characters for answer."""
71
 
72
  # Generate response
73
- response = model.generate_content(prompt)
74
 
75
  if response.text:
76
  # Parse the response to extract q: and a: lines
@@ -79,7 +78,7 @@ Keep each summary under 160 characters for question and 220 characters for answe
79
  al = next((l for l in lines if l.lower().startswith('a:')), None)
80
 
81
  if ql and al:
82
- return f"{ql}\n{al}"
83
 
84
  # Fallback if parsing fails
85
  logger.warning("Failed to parse Gemini summarization response, using fallback")
 
44
  """
45
  try:
46
  # Import Gemini client
47
+ from google import genai
48
 
49
  # Get API key from rotator
50
  api_key = rotator.get_key()
 
53
  return f"q: {question.strip()[:160]}\na: {answer.strip()[:220]}"
54
 
55
  # Configure Gemini
56
+ client = genai.Client(api_key=api_key)
 
57
 
58
  # Create prompt for summarization
59
  prompt = f"""You are a medical summarizer. Create a concise summary of this Q&A exchange.
 
69
  Keep each summary under 160 characters for question and 220 characters for answer."""
70
 
71
  # Generate response
72
+ response = client.models.generate_content(model="gemini-2.5-flash-lite", contents=prompt)
73
 
74
  if response.text:
75
  # Parse the response to extract q: and a: lines
 
78
  al = next((l for l in lines if l.lower().startswith('a:')), None)
79
 
80
  if ql and al:
81
+ return f"{ql}\n{al}"
82
 
83
  # Fallback if parsing fails
84
  logger.warning("Failed to parse Gemini summarization response, using fallback")
static/app.js CHANGED
@@ -14,6 +14,8 @@ class MedicalChatbotApp {
14
  this.setupEventListeners();
15
  this.loadUserPreferences();
16
  this.initializeUser();
 
 
17
  this.loadChatSessions();
18
  this.setupTheme();
19
  }
@@ -109,6 +111,24 @@ class MedicalChatbotApp {
109
  }
110
  });
111
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  }
113
 
114
  initializeUser() {
@@ -209,6 +229,28 @@ class MedicalChatbotApp {
209
  document.getElementById('chatInput').focus();
210
  }
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  getWelcomeMessage() {
213
  return `👋 Welcome to Medical AI Assistant
214
 
@@ -278,7 +320,7 @@ How can I assist you today?`;
278
  headers: {
279
  'Content-Type': 'application/json',
280
  },
281
- body: JSON.stringify({
282
  user_id: this.currentUser.id,
283
  session_id: this.currentSession?.id || 'default',
284
  message: message,
@@ -343,11 +385,40 @@ How can I assist you today?`;
343
  // Update UI
344
  this.displayMessage(message);
345
 
346
- // Update session title if it's the first user message
347
  if (role === 'user' && this.currentSession.messages.length === 2) {
348
- const title = content.length > 50 ? content.substring(0, 50) + '...' : content;
349
- this.currentSession.title = title;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  this.updateChatTitle();
 
351
  }
352
  }
353
 
@@ -484,20 +555,63 @@ How can I assist you today?`;
484
  <div class="chat-session-title">${session.title}</div>
485
  <div class="chat-session-time">${time}</div>
486
  </div>
487
- <button class="chat-session-delete" title="Delete chat" aria-label="Delete chat" data-session-id="${session.id}">
488
- <i class="fas fa-trash"></i>
489
- </button>
 
 
490
  </div>
491
  `;
492
 
493
  sessionsContainer.appendChild(sessionElement);
494
 
495
- // Wire delete button
496
- const deleteBtn = sessionElement.querySelector('.chat-session-delete');
497
- deleteBtn.addEventListener('click', (e) => {
498
  e.stopPropagation();
499
- const id = deleteBtn.getAttribute('data-session-id');
500
- this.deleteChatSession(id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  });
502
  });
503
  }
@@ -585,6 +699,19 @@ How can I assist you today?`;
585
  this.loadChatSessions();
586
  }
587
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  showUserModal() {
589
  // Populate form with current user data
590
  document.getElementById('profileName').value = this.currentUser.name;
 
14
  this.setupEventListeners();
15
  this.loadUserPreferences();
16
  this.initializeUser();
17
+ // Ensure a session exists and is displayed immediately
18
+ this.ensureStartupSession();
19
  this.loadChatSessions();
20
  this.setupTheme();
21
  }
 
111
  }
112
  });
113
  });
114
+
115
+ // Edit title modal wiring
116
+ const closeEdit = () => this.hideModal('editTitleModal');
117
+ const editTitleModal = document.getElementById('editTitleModal');
118
+ if (editTitleModal) {
119
+ document.getElementById('editTitleModalClose').addEventListener('click', closeEdit);
120
+ document.getElementById('editTitleModalCancel').addEventListener('click', closeEdit);
121
+ document.getElementById('editTitleModalSave').addEventListener('click', () => {
122
+ const input = document.getElementById('editSessionTitleInput');
123
+ const newTitle = input.value.trim();
124
+ if (!newTitle) return;
125
+ if (!this._pendingEditSessionId) return;
126
+ this.renameChatSession(this._pendingEditSessionId, newTitle);
127
+ this._pendingEditSessionId = null;
128
+ input.value = '';
129
+ this.hideModal('editTitleModal');
130
+ });
131
+ }
132
  }
133
 
134
  initializeUser() {
 
229
  document.getElementById('chatInput').focus();
230
  }
231
 
232
+ ensureStartupSession() {
233
+ const sessions = this.getChatSessions();
234
+ if (sessions.length === 0) {
235
+ // Create a new session immediately so it shows in sidebar
236
+ this.currentSession = {
237
+ id: this.generateId(),
238
+ title: 'New Chat',
239
+ messages: [],
240
+ createdAt: new Date().toISOString(),
241
+ lastActivity: new Date().toISOString()
242
+ };
243
+ this.saveCurrentSession();
244
+ this.updateChatTitle();
245
+ } else {
246
+ // Load the most recent session into view
247
+ this.currentSession = sessions[0];
248
+ this.clearChatMessages();
249
+ this.currentSession.messages.forEach(m => this.displayMessage(m));
250
+ this.updateChatTitle();
251
+ }
252
+ }
253
+
254
  getWelcomeMessage() {
255
  return `👋 Welcome to Medical AI Assistant
256
 
 
320
  headers: {
321
  'Content-Type': 'application/json',
322
  },
323
+ body: JSON.stringify({
324
  user_id: this.currentUser.id,
325
  session_id: this.currentSession?.id || 'default',
326
  message: message,
 
385
  // Update UI
386
  this.displayMessage(message);
387
 
388
+ // Update session title if it's the first user message -> call summarizer
389
  if (role === 'user' && this.currentSession.messages.length === 2) {
390
+ this.summarizeAndSetTitle(content);
391
+ }
392
+ }
393
+
394
+ async summarizeAndSetTitle(text) {
395
+ try {
396
+ const resp = await fetch('/summarize', {
397
+ method: 'POST',
398
+ headers: { 'Content-Type': 'application/json' },
399
+ body: JSON.stringify({ text, max_words: 5 })
400
+ });
401
+ if (resp.ok) {
402
+ const data = await resp.json();
403
+ const title = (data.title || 'New Chat').trim();
404
+ this.currentSession.title = title;
405
+ this.updateCurrentSession();
406
+ this.updateChatTitle();
407
+ this.loadChatSessions();
408
+ } else {
409
+ // Fallback: simple truncation
410
+ const fallback = text.length > 50 ? text.substring(0, 50) + '...' : text;
411
+ this.currentSession.title = fallback;
412
+ this.updateCurrentSession();
413
+ this.updateChatTitle();
414
+ this.loadChatSessions();
415
+ }
416
+ } catch (e) {
417
+ const fallback = text.length > 50 ? text.substring(0, 50) + '...' : text;
418
+ this.currentSession.title = fallback;
419
+ this.updateCurrentSession();
420
  this.updateChatTitle();
421
+ this.loadChatSessions();
422
  }
423
  }
424
 
 
555
  <div class="chat-session-title">${session.title}</div>
556
  <div class="chat-session-time">${time}</div>
557
  </div>
558
+ <div class="chat-session-actions">
559
+ <button class="chat-session-menu" title="Options" aria-label="Options" data-session-id="${session.id}">
560
+ <i class="fas fa-ellipsis-vertical"></i>
561
+ </button>
562
+ </div>
563
  </div>
564
  `;
565
 
566
  sessionsContainer.appendChild(sessionElement);
567
 
568
+ // Wire 3-dot menu
569
+ const menuBtn = sessionElement.querySelector('.chat-session-menu');
570
+ menuBtn.addEventListener('click', (e) => {
571
  e.stopPropagation();
572
+ this.showSessionMenu(e.currentTarget, session.id);
573
+ });
574
+ });
575
+ }
576
+
577
+ showSessionMenu(anchorEl, sessionId) {
578
+ // Remove existing popover
579
+ document.querySelectorAll('.chat-session-menu-popover').forEach(p => p.remove());
580
+ const rect = anchorEl.getBoundingClientRect();
581
+ const pop = document.createElement('div');
582
+ pop.className = 'chat-session-menu-popover show';
583
+ pop.innerHTML = `
584
+ <div class="chat-session-menu-item" data-action="edit" data-session-id="${sessionId}"><i class="fas fa-pen"></i> Edit Name</div>
585
+ <div class="chat-session-menu-item" data-action="delete" data-session-id="${sessionId}"><i class="fas fa-trash"></i> Delete</div>
586
+ `;
587
+ document.body.appendChild(pop);
588
+ // Position near button
589
+ pop.style.top = `${rect.bottom + window.scrollY + 6}px`;
590
+ pop.style.left = `${rect.right + window.scrollX - pop.offsetWidth}px`;
591
+
592
+ const onDocClick = (ev) => {
593
+ if (!pop.contains(ev.target) && ev.target !== anchorEl) {
594
+ pop.remove();
595
+ document.removeEventListener('click', onDocClick);
596
+ }
597
+ };
598
+ setTimeout(() => document.addEventListener('click', onDocClick), 0);
599
+
600
+ pop.querySelectorAll('.chat-session-menu-item').forEach(item => {
601
+ item.addEventListener('click', (e) => {
602
+ const action = item.getAttribute('data-action');
603
+ const id = item.getAttribute('data-session-id');
604
+ if (action === 'delete') {
605
+ this.deleteChatSession(id);
606
+ } else if (action === 'edit') {
607
+ this._pendingEditSessionId = id;
608
+ const sessions = this.getChatSessions();
609
+ const s = sessions.find(x => x.id === id);
610
+ const input = document.getElementById('editSessionTitleInput');
611
+ input.value = s ? s.title : '';
612
+ this.showModal('editTitleModal');
613
+ }
614
+ pop.remove();
615
  });
616
  });
617
  }
 
699
  this.loadChatSessions();
700
  }
701
 
702
+ renameChatSession(sessionId, newTitle) {
703
+ const sessions = this.getChatSessions();
704
+ const idx = sessions.findIndex(s => s.id === sessionId);
705
+ if (idx === -1) return;
706
+ sessions[idx] = { ...sessions[idx], title: newTitle };
707
+ localStorage.setItem(`chatSessions_${this.currentUser.id}`, JSON.stringify(sessions));
708
+ if (this.currentSession && this.currentSession.id === sessionId) {
709
+ this.currentSession.title = newTitle;
710
+ this.updateChatTitle();
711
+ }
712
+ this.loadChatSessions();
713
+ }
714
+
715
  showUserModal() {
716
  // Populate form with current user data
717
  document.getElementById('profileName').value = this.currentUser.name;
static/index.html CHANGED
@@ -194,6 +194,26 @@
194
  </div>
195
  </div>
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  <!-- Loading overlay -->
198
  <div class="loading-overlay" id="loadingOverlay">
199
  <div class="loading-spinner">
 
194
  </div>
195
  </div>
196
 
197
+ <!-- Edit Session Title Modal -->
198
+ <div class="modal" id="editTitleModal">
199
+ <div class="modal-content">
200
+ <div class="modal-header">
201
+ <h3>Edit Chat Name</h3>
202
+ <button class="modal-close" id="editTitleModalClose">&times;</button>
203
+ </div>
204
+ <div class="modal-body">
205
+ <div class="form-group">
206
+ <label for="editSessionTitleInput">New name</label>
207
+ <input type="text" id="editSessionTitleInput" placeholder="Enter new chat name">
208
+ </div>
209
+ </div>
210
+ <div class="modal-footer">
211
+ <button class="btn btn-secondary" id="editTitleModalCancel">Cancel</button>
212
+ <button class="btn btn-primary" id="editTitleModalSave">Save</button>
213
+ </div>
214
+ </div>
215
+ </div>
216
+
217
  <!-- Loading overlay -->
218
  <div class="loading-overlay" id="loadingOverlay">
219
  <div class="loading-spinner">
static/styles.css CHANGED
@@ -244,6 +244,61 @@ body {
244
  background-color: var(--bg-tertiary);
245
  }
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  .sidebar-footer {
248
  padding: var(--spacing-lg);
249
  border-top: 1px solid var(--border-color);
 
244
  background-color: var(--bg-tertiary);
245
  }
246
 
247
+ .chat-session-actions {
248
+ display: flex;
249
+ align-items: center;
250
+ gap: var(--spacing-xs);
251
+ }
252
+
253
+ .chat-session-menu {
254
+ background: none;
255
+ border: none;
256
+ color: var(--text-muted);
257
+ padding: var(--spacing-xs);
258
+ border-radius: 6px;
259
+ cursor: pointer;
260
+ opacity: 0;
261
+ transition: opacity var(--transition-fast), color var(--transition-fast), background-color var(--transition-fast);
262
+ }
263
+
264
+ .chat-session:hover .chat-session-menu {
265
+ opacity: 1;
266
+ }
267
+
268
+ .chat-session-menu:hover {
269
+ background-color: var(--bg-tertiary);
270
+ color: var(--text-primary);
271
+ }
272
+
273
+ .chat-session-menu-popover {
274
+ position: absolute;
275
+ background-color: var(--bg-primary);
276
+ border: 1px solid var(--border-color);
277
+ border-radius: 8px;
278
+ box-shadow: var(--shadow-lg);
279
+ padding: var(--spacing-xs);
280
+ display: none;
281
+ z-index: 4000;
282
+ }
283
+
284
+ .chat-session-menu-popover.show {
285
+ display: block;
286
+ }
287
+
288
+ .chat-session-menu-item {
289
+ display: flex;
290
+ align-items: center;
291
+ gap: var(--spacing-sm);
292
+ padding: var(--spacing-sm) var(--spacing-md);
293
+ cursor: pointer;
294
+ border-radius: 6px;
295
+ color: var(--text-secondary);
296
+ }
297
+
298
+ .chat-session-menu-item:hover {
299
+ background-color: var(--bg-tertiary);
300
+ }
301
+
302
  .sidebar-footer {
303
  padding: var(--spacing-lg);
304
  border-top: 1px solid var(--border-color);
utils/naming.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from typing import Optional
3
+
4
+ from .rotator import APIKeyRotator, robust_post_json
5
+ from .logger import get_logger
6
+
7
+ logger = get_logger("NAMING", __name__)
8
+
9
+
10
+ async def summarize_title(text: str, rotator: Optional[APIKeyRotator], max_words: int = 5) -> str:
11
+ """
12
+ Generate a concise 3-5 word title for the conversation using NVIDIA API when available.
13
+ Falls back to a heuristic if the API is unavailable.
14
+ """
15
+ max_words = max(3, min(max_words or 5, 7))
16
+ prompt = (
17
+ "Summarize the user's first chat message into a very short title of 3-5 words. "
18
+ "Only return the title text without quotes or punctuation. Message: " + (text or "New Chat")
19
+ )
20
+
21
+ if rotator and rotator.get_key():
22
+ try:
23
+ url = "https://integrate.api.nvidia.com/v1/chat/completions"
24
+ headers = {
25
+ "Authorization": f"Bearer {rotator.get_key()}",
26
+ "Content-Type": "application/json",
27
+ }
28
+ payload = {
29
+ "model": "meta/llama-3.1-8b-instruct",
30
+ "messages": [
31
+ {"role": "system", "content": "You generate extremely concise titles."},
32
+ {"role": "user", "content": prompt},
33
+ ],
34
+ "temperature": 0.2,
35
+ "max_tokens": 16,
36
+ }
37
+ data = await robust_post_json(url, headers, payload, rotator, max_retries=5)
38
+ # OpenAI-style response
39
+ title = (
40
+ data.get("choices", [{}])[0]
41
+ .get("message", {})
42
+ .get("content", "")
43
+ .strip()
44
+ )
45
+ title = _sanitize_title(title, max_words)
46
+ if title:
47
+ return title
48
+ except Exception as e:
49
+ logger.warning(f"NVIDIA summarize failed, using fallback: {e}")
50
+
51
+ # Fallback heuristic
52
+ return _heuristic_title(text, max_words)
53
+
54
+
55
+ def _sanitize_title(title: str, max_words: int) -> str:
56
+ title = title.strip()
57
+ title = re.sub(r"[\n\r]+", " ", title)
58
+ title = re.sub(r"[\"'`]+", "", title)
59
+ title = re.sub(r"\s+", " ", title)
60
+ words = title.split()
61
+ if not words:
62
+ return ""
63
+ return " ".join(words[:max_words])
64
+
65
+
66
+ def _heuristic_title(text: str, max_words: int) -> str:
67
+ cleaned = (text or "New Chat").strip()
68
+ cleaned = re.sub(r"[\n\r]+", " ", cleaned)
69
+ cleaned = re.sub(r"[^\w\s]", "", cleaned)
70
+ cleaned = re.sub(r"\s+", " ", cleaned)
71
+ words = cleaned.split()
72
+ if not words:
73
+ return "New Chat"
74
+ return " ".join(words[:max_words])
75
+
76
+