Spaces:
Sleeping
Sleeping
Girish Jeswani commited on
Commit ·
8aecc96
1
Parent(s): 6071dfa
add unified context
Browse 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
|