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