lollms-client 0.20.10__py3-none-any.whl → 0.22.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lollms-client might be problematic. Click here for more details.

@@ -1,412 +1,664 @@
1
- # lollms_discussion.py
2
-
3
1
  import yaml
4
- from dataclasses import dataclass, field
5
- from typing import List, Dict, Optional, Union, Any
2
+ import json
3
+ import base64
4
+ import os
6
5
  import uuid
6
+ import shutil
7
+ import re
7
8
  from collections import defaultdict
9
+ from datetime import datetime
10
+ from typing import List, Dict, Optional, Union, Any, Type, Callable
11
+ from pathlib import Path
12
+ from types import SimpleNamespace
13
+
14
+ from sqlalchemy import (create_engine, Column, String, Text, Integer, DateTime,
15
+ ForeignKey, JSON, Boolean, LargeBinary, Index, Float)
16
+ from sqlalchemy.orm import sessionmaker, relationship, Session, declarative_base, declared_attr
17
+ from sqlalchemy.types import TypeDecorator
18
+ from sqlalchemy.orm.exc import NoResultFound
19
+
20
+ try:
21
+ from cryptography.fernet import Fernet, InvalidToken
22
+ from cryptography.hazmat.primitives import hashes
23
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
24
+ from cryptography.hazmat.backends import default_backend
25
+ ENCRYPTION_AVAILABLE = True
26
+ except ImportError:
27
+ ENCRYPTION_AVAILABLE = False
28
+
29
+ # Type hint placeholders for classes defined externally
30
+ if False:
31
+ from lollms_client import LollmsClient
32
+ from lollms_client.lollms_types import MSG_TYPE
33
+ from lollms_personality import LollmsPersonality
34
+
35
+ class EncryptedString(TypeDecorator):
36
+ """A SQLAlchemy TypeDecorator for field-level database encryption."""
37
+ impl = LargeBinary
38
+ cache_ok = True
39
+
40
+ def __init__(self, key: str, *args, **kwargs):
41
+ super().__init__(*args, **kwargs)
42
+ if not ENCRYPTION_AVAILABLE:
43
+ raise ImportError("'cryptography' is required for DB encryption.")
44
+ self.salt = b'lollms-fixed-salt-for-db-encryption'
45
+ kdf = PBKDF2HMAC(
46
+ algorithm=hashes.SHA256(), length=32, salt=self.salt,
47
+ iterations=480000, backend=default_backend()
48
+ )
49
+ derived_key = base64.urlsafe_b64encode(kdf.derive(key.encode()))
50
+ self.fernet = Fernet(derived_key)
8
51
 
9
- # It's good practice to forward-declare the type for the client to avoid circular imports.
10
- if False:
11
- from lollms.client import LollmsClient
52
+ def process_bind_param(self, value: Optional[str], dialect) -> Optional[bytes]:
53
+ if value is None:
54
+ return None
55
+ return self.fernet.encrypt(value.encode('utf-8'))
12
56
 
57
+ def process_result_value(self, value: Optional[bytes], dialect) -> Optional[str]:
58
+ if value is None:
59
+ return None
60
+ try:
61
+ return self.fernet.decrypt(value).decode('utf-8')
62
+ except InvalidToken:
63
+ return "<DECRYPTION_FAILED: Invalid Key or Corrupt Data>"
64
+
65
+ def create_dynamic_models(discussion_mixin: Optional[Type] = None, message_mixin: Optional[Type] = None, encryption_key: Optional[str] = None):
66
+ """Factory to dynamically create SQLAlchemy ORM models with custom mixins."""
67
+ Base = declarative_base()
68
+ EncryptedText = EncryptedString(encryption_key) if encryption_key else Text
69
+
70
+ class DiscussionBase:
71
+ __abstract__ = True
72
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
73
+ system_prompt = Column(EncryptedText, nullable=True)
74
+ participants = Column(JSON, nullable=True, default=dict)
75
+ active_branch_id = Column(String, nullable=True)
76
+ discussion_metadata = Column(JSON, nullable=True, default=dict)
77
+ created_at = Column(DateTime, default=datetime.utcnow)
78
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
79
+
80
+ @declared_attr
81
+ def messages(cls):
82
+ return relationship("Message", back_populates="discussion", cascade="all, delete-orphan", lazy="joined")
83
+
84
+ class MessageBase:
85
+ __abstract__ = True
86
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
87
+ discussion_id = Column(String, ForeignKey('discussions.id'), nullable=False, index=True)
88
+ parent_id = Column(String, ForeignKey('messages.id'), nullable=True, index=True)
89
+ sender = Column(String, nullable=False)
90
+ sender_type = Column(String, nullable=False)
91
+
92
+ raw_content = Column(EncryptedText, nullable=True)
93
+ thoughts = Column(EncryptedText, nullable=True)
94
+ content = Column(EncryptedText, nullable=False)
95
+ scratchpad = Column(EncryptedText, nullable=True)
96
+
97
+ tokens = Column(Integer, nullable=True)
98
+ binding_name = Column(String, nullable=True)
99
+ model_name = Column(String, nullable=True)
100
+ generation_speed = Column(Float, nullable=True)
101
+
102
+ message_metadata = Column(JSON, nullable=True, default=dict)
103
+ images = Column(JSON, nullable=True, default=list)
104
+ created_at = Column(DateTime, default=datetime.utcnow)
105
+
106
+ @declared_attr
107
+ def discussion(cls):
108
+ return relationship("Discussion", back_populates="messages")
109
+
110
+ discussion_bases = (discussion_mixin, DiscussionBase, Base) if discussion_mixin else (DiscussionBase, Base)
111
+ DynamicDiscussion = type('Discussion', discussion_bases, {'__tablename__': 'discussions'})
112
+
113
+ message_bases = (message_mixin, MessageBase, Base) if message_mixin else (MessageBase, Base)
114
+ DynamicMessage = type('Message', message_bases, {'__tablename__': 'messages'})
115
+
116
+ return Base, DynamicDiscussion, DynamicMessage
117
+
118
+ class LollmsDataManager:
119
+ """Manages database connection, session, and table creation."""
120
+ def __init__(self, db_path: str, discussion_mixin: Optional[Type] = None, message_mixin: Optional[Type] = None, encryption_key: Optional[str] = None):
121
+ if not db_path:
122
+ raise ValueError("Database path cannot be empty.")
123
+ self.Base, self.DiscussionModel, self.MessageModel = create_dynamic_models(
124
+ discussion_mixin, message_mixin, encryption_key
125
+ )
126
+ self.engine = create_engine(db_path)
127
+ self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
128
+ self.create_tables()
129
+
130
+ def create_tables(self):
131
+ self.Base.metadata.create_all(bind=self.engine)
132
+
133
+ def get_session(self) -> Session:
134
+ return self.SessionLocal()
135
+
136
+ def list_discussions(self) -> List[Dict]:
137
+ with self.get_session() as session:
138
+ discussions = session.query(self.DiscussionModel).all()
139
+ return [{c.name: getattr(disc, c.name) for c in disc.__table__.columns} for disc in discussions]
140
+
141
+ def get_discussion(self, lollms_client: 'LollmsClient', discussion_id: str, **kwargs) -> Optional['LollmsDiscussion']:
142
+ with self.get_session() as session:
143
+ try:
144
+ db_disc = session.query(self.DiscussionModel).filter_by(id=discussion_id).one()
145
+ session.expunge(db_disc)
146
+ return LollmsDiscussion(lollmsClient=lollms_client, db_manager=self, db_discussion_obj=db_disc, **kwargs)
147
+ except NoResultFound:
148
+ return None
149
+
150
+ def search_discussions(self, **criteria) -> List[Dict]:
151
+ with self.get_session() as session:
152
+ query = session.query(self.DiscussionModel)
153
+ for key, value in criteria.items():
154
+ if hasattr(self.DiscussionModel, key):
155
+ query = query.filter(getattr(self.DiscussionModel, key).ilike(f"%{value}%"))
156
+ discussions = query.all()
157
+ return [{c.name: getattr(disc, c.name) for c in disc.__table__.columns} for disc in discussions]
158
+
159
+ def delete_discussion(self, discussion_id: str):
160
+ with self.get_session() as session:
161
+ db_disc = session.query(self.DiscussionModel).filter_by(id=discussion_id).first()
162
+ if db_disc:
163
+ session.delete(db_disc)
164
+ session.commit()
13
165
 
14
- @dataclass
15
166
  class LollmsMessage:
16
- """
17
- Represents a single message in a LollmsDiscussion, including its content,
18
- sender, and relationship within the discussion tree.
19
- """
20
- sender: str
21
- sender_type: str
22
- content: str
23
- id: str = field(default_factory=lambda: str(uuid.uuid4()))
24
- parent_id: Optional[str] = None
25
- metadata: str = "{}"
26
- images: List[Dict[str, str]] = field(default_factory=list)
27
-
28
- def to_dict(self) -> Dict[str, Any]:
29
- """Serializes the message object to a dictionary."""
30
- return {
31
- 'sender': self.sender,
32
- 'sender_type': self.sender_type,
33
- 'content': self.content,
34
- 'id': self.id,
35
- 'parent_id': self.parent_id,
36
- 'metadata': self.metadata,
37
- 'images': self.images
38
- }
167
+ """A wrapper for a message ORM object, providing direct attribute access."""
168
+ def __init__(self, discussion: 'LollmsDiscussion', db_message: Any):
169
+ object.__setattr__(self, '_discussion', discussion)
170
+ object.__setattr__(self, '_db_message', db_message)
171
+
172
+ def __getattr__(self, name: str) -> Any:
173
+ if name == 'metadata':
174
+ return getattr(self._db_message, 'message_metadata', None)
175
+ return getattr(self._db_message, name)
176
+
177
+ def __setattr__(self, name: str, value: Any):
178
+ if name == 'metadata':
179
+ setattr(self._db_message, 'message_metadata', value)
180
+ else:
181
+ setattr(self._db_message, name, value)
182
+ self._discussion.touch()
39
183
 
184
+ def __repr__(self) -> str:
185
+ return f"<LollmsMessage id={self.id} sender='{self.sender}'>"
40
186
 
41
187
  class LollmsDiscussion:
42
- """
43
- Manages a branching conversation tree, including system prompts, participants,
44
- an internal knowledge scratchpad, and context pruning capabilities.
45
- """
46
-
47
- def __init__(self, lollmsClient: 'LollmsClient'):
48
- """
49
- Initializes a new LollmsDiscussion instance.
50
-
51
- Args:
52
- lollmsClient: An instance of LollmsClient, required for tokenization.
53
- """
54
- self.lollmsClient = lollmsClient
55
- self.version: int = 3 # Current version of the format with scratchpad support
56
- self._reset_state()
57
-
58
- def _reset_state(self):
59
- """Helper to reset all discussion attributes to their defaults."""
60
- self.messages: List[LollmsMessage] = []
61
- self.active_branch_id: Optional[str] = None
62
- self.message_index: Dict[str, LollmsMessage] = {}
63
- self.children_index: Dict[Optional[str], List[str]] = defaultdict(list)
64
- self.participants: Dict[str, str] = {}
65
- self.system_prompt: Optional[str] = None
66
- self.scratchpad: Optional[str] = None
67
-
68
- # --- Scratchpad Management Methods ---
69
- def set_scratchpad(self, content: str):
70
- """Sets or replaces the entire content of the internal scratchpad."""
71
- self.scratchpad = content
72
-
73
- def update_scratchpad(self, new_content: str, append: bool = True):
74
- """
75
- Updates the scratchpad. By default, it appends with a newline separator.
76
-
77
- Args:
78
- new_content: The new text to add to the scratchpad.
79
- append: If True, appends to existing content. If False, replaces it.
80
- """
81
- if append and self.scratchpad:
82
- self.scratchpad += f"\n{new_content}"
83
- else:
84
- self.scratchpad = new_content
85
-
86
- def get_scratchpad(self) -> Optional[str]:
87
- """Returns the current content of the scratchpad."""
88
- return self.scratchpad
89
-
90
- def clear_scratchpad(self):
91
- """Clears the scratchpad content."""
92
- self.scratchpad = None
93
-
94
- # --- Configuration Methods ---
95
- def set_system_prompt(self, prompt: str):
96
- """Sets the main system prompt for the discussion."""
97
- self.system_prompt = prompt
98
-
99
- def set_participants(self, participants: Dict[str, str]):
100
- """
101
- Defines the participants and their roles ('user' or 'assistant').
102
-
103
- Args:
104
- participants: A dictionary mapping sender names to roles.
105
- """
106
- for name, role in participants.items():
107
- if role not in ["user", "assistant"]:
108
- raise ValueError(f"Invalid role '{role}' for participant '{name}'")
109
- self.participants = participants
110
-
111
- # --- Core Message Tree Methods ---
112
- def add_message(
113
- self,
114
- sender: str,
115
- sender_type: str,
116
- content: str,
117
- metadata: Optional[Dict] = None,
118
- parent_id: Optional[str] = None,
119
- images: Optional[List[Dict[str, str]]] = None,
120
- override_id: Optional[str] = None
121
- ) -> str:
122
- """
123
- Adds a new message to the discussion tree.
124
- """
125
- if parent_id is None:
126
- parent_id = self.active_branch_id
127
- if parent_id is None:
128
- parent_id = "main_root"
129
-
130
- message = LollmsMessage(
131
- sender=sender, sender_type=sender_type, content=content,
132
- parent_id=parent_id, metadata=str(metadata or {}), images=images or []
133
- )
134
- if override_id:
135
- message.id = override_id
136
-
137
- self.messages.append(message)
138
- self.message_index[message.id] = message
139
- self.children_index[parent_id].append(message.id)
140
- self.active_branch_id = message.id
141
- return message.id
142
-
143
- def get_branch(self, leaf_id: str) -> List[LollmsMessage]:
144
- """Gets the full branch of messages from the root to the specified leaf."""
145
- branch = []
146
- current_id: Optional[str] = leaf_id
147
- while current_id and current_id in self.message_index:
148
- msg = self.message_index[current_id]
149
- branch.append(msg)
150
- current_id = msg.parent_id
151
- return list(reversed(branch))
152
-
153
- def set_active_branch(self, message_id: str):
154
- """Sets the active message, effectively switching to a different branch."""
155
- if message_id not in self.message_index:
156
- raise ValueError(f"Message ID {message_id} not found in discussion.")
157
- self.active_branch_id = message_id
158
-
159
- # --- Persistence ---
160
- def save_to_disk(self, file_path: str):
161
- """Saves the entire discussion state to a YAML file."""
162
- data = {
163
- 'version': self.version, 'active_branch_id': self.active_branch_id,
164
- 'system_prompt': self.system_prompt, 'participants': self.participants,
165
- 'scratchpad': self.scratchpad, 'messages': [m.to_dict() for m in self.messages]
166
- }
167
- with open(file_path, 'w', encoding='utf-8') as file:
168
- yaml.dump(data, file, allow_unicode=True, sort_keys=False)
169
-
170
- def load_from_disk(self, file_path: str):
171
- """Loads a discussion state from a YAML file."""
172
- with open(file_path, 'r', encoding='utf-8') as file:
173
- data = yaml.safe_load(file)
174
-
175
- self._reset_state()
176
- version = data.get("version", 1)
177
- if version > self.version:
178
- raise ValueError(f"File version {version} is newer than supported version {self.version}.")
179
-
180
- self.active_branch_id = data.get('active_branch_id')
181
- self.system_prompt = data.get('system_prompt', None)
182
- self.participants = data.get('participants', {})
183
- self.scratchpad = data.get('scratchpad', None)
184
-
185
- for msg_data in data.get('messages', []):
186
- msg = LollmsMessage(
187
- sender=msg_data['sender'], sender_type=msg_data.get('sender_type', 'user'),
188
- content=msg_data['content'], parent_id=msg_data.get('parent_id'),
189
- id=msg_data.get('id', str(uuid.uuid4())), metadata=msg_data.get('metadata', '{}'),
190
- images=msg_data.get('images', [])
191
- )
192
- self.messages.append(msg)
193
- self.message_index[msg.id] = msg
194
- self.children_index[msg.parent_id].append(msg.id)
195
-
196
- # --- Context Management and Formatting ---
197
- def _get_full_system_prompt(self) -> Optional[str]:
198
- """Combines the scratchpad and system prompt into a single string for the LLM."""
199
- full_sys_prompt_parts = []
200
- if self.scratchpad and self.scratchpad.strip():
201
- full_sys_prompt_parts.append("--- KNOWLEDGE SCRATCHPAD ---")
202
- full_sys_prompt_parts.append(self.scratchpad.strip())
203
- full_sys_prompt_parts.append("--- END SCRATCHPAD ---")
188
+ """Represents and manages a single discussion, acting as a high-level interface."""
189
+ def __init__(self, lollmsClient: 'LollmsClient', db_manager: Optional[LollmsDataManager] = None,
190
+ discussion_id: Optional[str] = None, db_discussion_obj: Optional[Any] = None,
191
+ autosave: bool = False, max_context_size: Optional[int] = None):
204
192
 
205
- if self.system_prompt and self.system_prompt.strip():
206
- full_sys_prompt_parts.append(self.system_prompt.strip())
207
- return "\n\n".join(full_sys_prompt_parts) if full_sys_prompt_parts else None
208
-
209
- def summarize_and_prune(self, max_tokens: int, preserve_last_n: int = 4, branch_tip_id: Optional[str] = None) -> Dict[str, Any]:
210
- """
211
- Checks context size and, if exceeded, summarizes the oldest messages
212
- into the scratchpad and prunes them to free up token space.
213
- """
214
- if branch_tip_id is None: branch_tip_id = self.active_branch_id
215
- if not branch_tip_id: return {"pruned": False, "reason": "No active branch."}
216
-
217
- full_prompt_text = self.export("lollms_text", branch_tip_id)
218
- current_tokens = len(self.lollmsClient.binding.tokenize(full_prompt_text))
219
- if current_tokens <= max_tokens: return {"pruned": False, "reason": "Token count within limit."}
220
-
221
- branch = self.get_branch(branch_tip_id)
222
- if len(branch) <= preserve_last_n: return {"pruned": False, "reason": "Not enough messages to prune."}
223
-
224
- messages_to_prune = branch[:-preserve_last_n]
225
- messages_to_keep = branch[-preserve_last_n:]
226
- text_to_summarize = "\n\n".join([f"{self.participants.get(m.sender, 'user').capitalize()}: {m.content}" for m in messages_to_prune])
193
+ object.__setattr__(self, 'lollmsClient', lollmsClient)
194
+ object.__setattr__(self, 'db_manager', db_manager)
195
+ object.__setattr__(self, 'autosave', autosave)
196
+ object.__setattr__(self, 'max_context_size', max_context_size)
197
+ object.__setattr__(self, 'scratchpad', "")
198
+ object.__setattr__(self, 'show_thoughts', False)
199
+ object.__setattr__(self, 'include_thoughts_in_context', False)
200
+ object.__setattr__(self, 'thought_placeholder', "<thought process hidden>")
227
201
 
228
- summary_prompt = (
229
- "You are a summarization expert. Read the following conversation excerpt and create a "
230
- "concise, factual summary of all key information, decisions, and outcomes. This summary "
231
- "will be placed in a knowledge scratchpad for future reference. Omit conversational filler.\n\n"
232
- f"CONVERSATION EXCERPT:\n---\n{text_to_summarize}\n---\n\nCONCISE SUMMARY:"
233
- )
202
+ object.__setattr__(self, '_session', None)
203
+ object.__setattr__(self, '_db_discussion', None)
204
+ object.__setattr__(self, '_message_index', None)
205
+ object.__setattr__(self, '_messages_to_delete_from_db', set())
206
+ object.__setattr__(self, '_is_db_backed', db_manager is not None)
207
+
208
+ if self._is_db_backed:
209
+ if not db_discussion_obj and not discussion_id:
210
+ raise ValueError("Either discussion_id or db_discussion_obj must be provided for DB-backed discussions.")
211
+
212
+ self._session = db_manager.get_session()
213
+ if db_discussion_obj:
214
+ self._db_discussion = self._session.merge(db_discussion_obj)
215
+ else:
216
+ try:
217
+ self._db_discussion = self._session.query(db_manager.DiscussionModel).filter_by(id=discussion_id).one()
218
+ except NoResultFound:
219
+ self._session.close()
220
+ raise ValueError(f"No discussion found with ID: {discussion_id}")
221
+ else:
222
+ self._create_in_memory_proxy(id=discussion_id)
223
+ self._rebuild_message_index()
224
+
225
+ @property
226
+ def remaining_tokens(self) -> Optional[int]:
227
+ """Calculates the remaining tokens available in the context window."""
228
+ binding = self.lollmsClient.binding
229
+ if not binding or not hasattr(binding, 'ctx_size') or not binding.ctx_size:
230
+ return None
231
+ max_ctx = binding.ctx_size
232
+ current_prompt = self.format_discussion(max_ctx)
233
+ current_tokens = self.lollmsClient.count_tokens(current_prompt)
234
+ return max_ctx - current_tokens
235
+
236
+ @classmethod
237
+ def create_new(cls, lollms_client: 'LollmsClient', db_manager: Optional[LollmsDataManager] = None, **kwargs) -> 'LollmsDiscussion':
238
+ init_args = {
239
+ 'autosave': kwargs.pop('autosave', False),
240
+ 'max_context_size': kwargs.pop('max_context_size', None)
241
+ }
242
+ if db_manager:
243
+ with db_manager.get_session() as session:
244
+ valid_keys = db_manager.DiscussionModel.__table__.columns.keys()
245
+ db_creation_args = {k: v for k, v in kwargs.items() if k in valid_keys}
246
+ db_discussion_orm = db_manager.DiscussionModel(**db_creation_args)
247
+ session.add(db_discussion_orm)
248
+ session.commit()
249
+ session.expunge(db_discussion_orm)
250
+ return cls(lollmsClient=lollms_client, db_manager=db_manager, db_discussion_obj=db_discussion_orm, **init_args)
251
+ else:
252
+ return cls(lollmsClient=lollms_client, discussion_id=kwargs.get('id'), **init_args)
253
+
254
+ def __getattr__(self, name: str) -> Any:
255
+ if name == 'metadata':
256
+ return getattr(self._db_discussion, 'discussion_metadata', None)
257
+ if name == 'messages':
258
+ return [LollmsMessage(self, msg) for msg in self._db_discussion.messages]
259
+ return getattr(self._db_discussion, name)
260
+
261
+ def __setattr__(self, name: str, value: Any):
262
+ internal_attrs = [
263
+ 'lollmsClient','db_manager','autosave','max_context_size','scratchpad',
264
+ 'show_thoughts', 'include_thoughts_in_context', 'thought_placeholder',
265
+ '_session','_db_discussion','_message_index','_messages_to_delete_from_db', '_is_db_backed'
266
+ ]
267
+ if name in internal_attrs:
268
+ object.__setattr__(self, name, value)
269
+ else:
270
+ if name == 'metadata':
271
+ setattr(self._db_discussion, 'discussion_metadata', value)
272
+ else:
273
+ setattr(self._db_discussion, name, value)
274
+ self.touch()
275
+
276
+ def _create_in_memory_proxy(self, id: Optional[str] = None):
277
+ proxy = SimpleNamespace()
278
+ proxy.id, proxy.system_prompt, proxy.participants = id or str(uuid.uuid4()), None, {}
279
+ proxy.active_branch_id, proxy.discussion_metadata = None, {}
280
+ proxy.created_at, proxy.updated_at = datetime.utcnow(), datetime.utcnow()
281
+ proxy.messages = []
282
+ object.__setattr__(self, '_db_discussion', proxy)
283
+
284
+ def _rebuild_message_index(self):
285
+ if self._is_db_backed and self._session.is_active and self._db_discussion in self._session:
286
+ self._session.refresh(self._db_discussion, ['messages'])
287
+ self._message_index = {msg.id: msg for msg in self._db_discussion.messages}
288
+
289
+ def touch(self):
290
+ setattr(self._db_discussion, 'updated_at', datetime.utcnow())
291
+ if self._is_db_backed and self.autosave:
292
+ self.commit()
293
+
294
+ def commit(self):
295
+ if not self._is_db_backed or not self._session:
296
+ return
297
+ if self._messages_to_delete_from_db:
298
+ for msg_id in self._messages_to_delete_from_db:
299
+ msg_to_del = self._session.get(self.db_manager.MessageModel, msg_id)
300
+ if msg_to_del:
301
+ self._session.delete(msg_to_del)
302
+ self._messages_to_delete_from_db.clear()
234
303
  try:
235
- summary = self.lollmsClient.generate_text(summary_prompt, max_new_tokens=300, temperature=0.1)
304
+ self._session.commit()
305
+ self._rebuild_message_index()
236
306
  except Exception as e:
237
- return {"pruned": False, "reason": f"Failed to generate summary: {e}"}
238
-
239
- summary_block = f"--- Summary of earlier conversation (pruned on {uuid.uuid4().hex[:8]}) ---\n{summary.strip()}"
240
- self.update_scratchpad(summary_block, append=True)
241
-
242
- ids_to_prune = {msg.id for msg in messages_to_prune}
243
- new_root_of_branch = messages_to_keep[0]
244
- original_parent_id = messages_to_prune[0].parent_id
245
-
246
- self.message_index[new_root_of_branch.id].parent_id = original_parent_id
247
- if original_parent_id in self.children_index:
248
- self.children_index[original_parent_id] = [mid for mid in self.children_index[original_parent_id] if mid != messages_to_prune[0].id]
249
- self.children_index[original_parent_id].append(new_root_of_branch.id)
250
-
251
- for msg_id in ids_to_prune:
252
- self.message_index.pop(msg_id, None)
253
- self.children_index.pop(msg_id, None)
254
- self.messages = [m for m in self.messages if m.id not in ids_to_prune]
255
-
256
- new_prompt_text = self.export("lollms_text", branch_tip_id)
257
- new_tokens = len(self.lollmsClient.binding.tokenize(new_prompt_text))
258
- return {"pruned": True, "tokens_saved": current_tokens - new_tokens, "summary_added": True}
259
-
260
- def format_discussion(self, max_allowed_tokens: int, splitter_text: str = "!@>", branch_tip_id: Optional[str] = None) -> str:
261
- """
262
- Formats the discussion into a single string for instruct models,
263
- truncating from the start to respect the token limit.
264
-
265
- Args:
266
- max_allowed_tokens: The maximum token limit for the final prompt.
267
- splitter_text: The separator token to use (e.g., '!@>').
268
- branch_tip_id: The ID of the branch to format. Defaults to active.
269
-
270
- Returns:
271
- A single, truncated prompt string.
272
- """
273
- if branch_tip_id is None:
274
- branch_tip_id = self.active_branch_id
307
+ self._session.rollback()
308
+ raise e
309
+
310
+ def close(self):
311
+ if self._session:
312
+ self.commit()
313
+ self._session.close()
314
+
315
+ def add_message(self, **kwargs) -> LollmsMessage:
316
+ msg_id, parent_id = kwargs.get('id', str(uuid.uuid4())), kwargs.get('parent_id', self.active_branch_id)
317
+ message_data = {'id': msg_id, 'parent_id': parent_id, 'discussion_id': self.id, 'created_at': datetime.utcnow(), **kwargs}
318
+ if 'metadata' in message_data:
319
+ message_data['message_metadata'] = message_data.pop('metadata')
320
+ if self._is_db_backed:
321
+ valid_keys = {c.name for c in self.db_manager.MessageModel.__table__.columns}
322
+ filtered_data = {k: v for k, v in message_data.items() if k in valid_keys}
323
+ new_msg_orm = self.db_manager.MessageModel(**filtered_data)
324
+ self._db_discussion.messages.append(new_msg_orm)
325
+ if new_msg_orm not in self._session:
326
+ self._session.add(new_msg_orm)
327
+ else:
328
+ new_msg_orm = SimpleNamespace(**message_data)
329
+ self._db_discussion.messages.append(new_msg_orm)
330
+ self._message_index[msg_id], self.active_branch_id = new_msg_orm, msg_id
331
+ self.touch()
332
+ return LollmsMessage(self, new_msg_orm)
275
333
 
276
- branch_msgs = self.get_branch(branch_tip_id) if branch_tip_id else []
277
- full_system_prompt = self._get_full_system_prompt()
334
+ def get_branch(self, leaf_id: Optional[str]) -> List[LollmsMessage]:
335
+ if not leaf_id:
336
+ return []
337
+ branch_orms, current_id = [], leaf_id
338
+ while current_id and current_id in self._message_index:
339
+ msg_orm = self._message_index[current_id]
340
+ branch_orms.append(msg_orm)
341
+ current_id = msg_orm.parent_id
342
+ return [LollmsMessage(self, orm) for orm in reversed(branch_orms)]
343
+
344
+ def chat(self, user_message: str, personality: Optional['LollmsPersonality'] = None, **kwargs) -> LollmsMessage:
345
+ if self.max_context_size is not None:
346
+ self.summarize_and_prune(self.max_context_size)
347
+
348
+ if user_message:
349
+ self.add_message(sender="user", sender_type="user", content=user_message)
350
+
351
+ rag_context = None
352
+ original_system_prompt = self.system_prompt
353
+ if personality:
354
+ self.system_prompt = personality.system_prompt
355
+ if user_message:
356
+ rag_context = personality.get_rag_context(user_message)
278
357
 
279
- prompt_parts = []
280
- current_tokens = 0
358
+ if rag_context:
359
+ self.system_prompt = f"{original_system_prompt or ''}\n\n--- Relevant Information ---\n{rag_context}\n---"
360
+
361
+ from lollms_client.lollms_types import MSG_TYPE
362
+ is_streaming = "streaming_callback" in kwargs and kwargs.get("streaming_callback") is not None
281
363
 
282
- # Start with the system prompt if defined
283
- if full_system_prompt:
284
- sys_msg_text = f"{splitter_text}system:\n{full_system_prompt}\n"
285
- sys_tokens = len(self.lollmsClient.binding.tokenize(sys_msg_text))
286
- if sys_tokens <= max_allowed_tokens:
287
- prompt_parts.append(sys_msg_text)
288
- current_tokens += sys_tokens
364
+ final_raw_response = ""
365
+ start_time = datetime.now()
366
+
367
+ if personality and personality.script_module and hasattr(personality.script_module, 'run'):
368
+ try:
369
+ print(f"[{personality.name}] Running custom script...")
370
+ final_raw_response = personality.script_module.run(self, kwargs.get("streaming_callback"))
371
+ except Exception as e:
372
+ print(f"[{personality.name}] Error in custom script: {e}")
373
+ final_raw_response = f"Error executing personality script: {e}"
374
+ else:
375
+ raw_response_accumulator = []
376
+ if is_streaming:
377
+ full_response_parts, token_buffer, in_thought_block = [], "", False
378
+ original_callback = kwargs.get("streaming_callback")
379
+ def accumulating_callback(token: str, msg_type: MSG_TYPE = MSG_TYPE.MSG_TYPE_CHUNK):
380
+ nonlocal token_buffer, in_thought_block
381
+ raw_response_accumulator.append(token)
382
+ continue_streaming = True
383
+ if token: token_buffer += token
384
+ while True:
385
+ if in_thought_block:
386
+ end_tag_pos = token_buffer.find("</think>")
387
+ if end_tag_pos != -1:
388
+ thought_chunk = token_buffer[:end_tag_pos]
389
+ if self.show_thoughts and original_callback and thought_chunk:
390
+ if not original_callback(thought_chunk, MSG_TYPE.MSG_TYPE_THOUGHT_CHUNK): continue_streaming = False
391
+ in_thought_block, token_buffer = False, token_buffer[end_tag_pos + len("</think>"):]
392
+ else:
393
+ if self.show_thoughts and original_callback and token_buffer:
394
+ if not original_callback(token_buffer, MSG_TYPE.MSG_TYPE_THOUGHT_CHUNK): continue_streaming = False
395
+ token_buffer = ""; break
396
+ else:
397
+ start_tag_pos = token_buffer.find("<think>")
398
+ if start_tag_pos != -1:
399
+ response_chunk = token_buffer[:start_tag_pos]
400
+ if response_chunk:
401
+ full_response_parts.append(response_chunk)
402
+ if original_callback:
403
+ if not original_callback(response_chunk, MSG_TYPE.MSG_TYPE_CHUNK): continue_streaming = False
404
+ in_thought_block, token_buffer = True, token_buffer[start_tag_pos + len("<think>"):]
405
+ else:
406
+ if token_buffer:
407
+ full_response_parts.append(token_buffer)
408
+ if original_callback:
409
+ if not original_callback(token_buffer, MSG_TYPE.MSG_TYPE_CHUNK): continue_streaming = False
410
+ token_buffer = ""; break
411
+ return continue_streaming
412
+ kwargs["streaming_callback"], kwargs["stream"] = accumulating_callback, True
413
+ self.lollmsClient.chat(self, **kwargs)
414
+ final_raw_response = "".join(raw_response_accumulator)
415
+ else:
416
+ kwargs["stream"] = False
417
+ final_raw_response = self.lollmsClient.chat(self, **kwargs) or ""
418
+
419
+ end_time = datetime.now()
420
+ if rag_context:
421
+ self.system_prompt = original_system_prompt
422
+
423
+ duration = (end_time - start_time).total_seconds()
424
+ thoughts_match = re.search(r"<think>(.*?)</think>", final_raw_response, re.DOTALL)
425
+ thoughts_text = thoughts_match.group(1).strip() if thoughts_match else None
426
+ final_content = self.lollmsClient.remove_thinking_blocks(final_raw_response)
427
+ token_count = self.lollmsClient.count_tokens(final_content)
428
+ tok_per_sec = (token_count / duration) if duration > 0 else 0
289
429
 
290
- # Iterate from newest to oldest to fill the remaining context
291
- for msg in reversed(branch_msgs):
292
- sender_str = msg.sender.replace(':', '').replace(splitter_text, '')
293
- content = msg.content.strip()
294
- if msg.images:
295
- content += f"\n({len(msg.images)} image(s) attached)"
430
+ ai_message_obj = self.add_message(
431
+ sender="assistant", sender_type="assistant", content=final_content,
432
+ raw_content=final_raw_response, thoughts=thoughts_text, tokens=token_count,
433
+ binding_name=self.lollmsClient.binding.binding_name, model_name=self.lollmsClient.binding.model_name,
434
+ generation_speed=tok_per_sec
435
+ )
296
436
 
297
- msg_text = f"{splitter_text}{sender_str}:\n{content}\n"
298
- msg_tokens = len(self.lollmsClient.binding.tokenize(msg_text))
437
+ if self._is_db_backed and not self.autosave:
438
+ self.commit()
439
+ return ai_message_obj
440
+
441
+ def process_and_summarize(self, large_text: str, user_prompt: str, chunk_size: int = 4096, **kwargs) -> LollmsMessage:
442
+ user_msg = self.add_message(sender="user", sender_type="user", content=user_prompt)
443
+ chunks = [large_text[i:i + chunk_size] for i in range(0, len(large_text), chunk_size)]
444
+ current_summary, total_chunks = "", len(chunks)
445
+ for i, chunk in enumerate(chunks):
446
+ print(f"\nProcessing chunk {i+1}/{total_chunks}...")
447
+ if i == 0:
448
+ prompt = f"""The user wants to know: "{user_prompt}"\nHere is the first part of the document (chunk 1 of {total_chunks}). \nRead it and create a detailed summary of all information relevant to the user's prompt.\n\nDOCUMENT CHUNK:\n---\n{chunk}\n---\nSUMMARY:"""
449
+ else:
450
+ prompt = f"""The user wants to know: "{user_prompt}"\nYou are processing a large document sequentially. Here is the summary of the previous chunks and the content of the next chunk ({i+1} of {total_chunks}).\nUpdate your summary by integrating new relevant information from the new chunk. Do not repeat information you already have. Output ONLY the new, updated, complete summary.\n\nPREVIOUS SUMMARY:\n---\n{current_summary}\n---\n\nNEW DOCUMENT CHUNK:\n---\n{chunk}\n---\nUPDATED SUMMARY:"""
451
+ current_summary = self.lollmsClient.generate_text(prompt, **kwargs).strip()
452
+ final_prompt = f"""Based on the following comprehensive summary of a document, provide a final answer to the user's original prompt.\nUser's prompt: "{user_prompt}"\n\nCOMPREHENSIVE SUMMARY:\n---\n{current_summary}\n---\nFINAL ANSWER:"""
453
+ final_answer = self.lollmsClient.generate_text(final_prompt, **kwargs).strip()
454
+ ai_message_obj = self.add_message(
455
+ sender="assistant", sender_type="assistant", content=final_answer,
456
+ scratchpad=current_summary, parent_id=user_msg.id
457
+ )
458
+ if self._is_db_backed and not self.autosave:
459
+ self.commit()
460
+ return ai_message_obj
461
+
462
+ def regenerate_branch(self, **kwargs) -> LollmsMessage:
463
+ if not self.active_branch_id or self.active_branch_id not in self._message_index:
464
+ raise ValueError("No active message to regenerate from.")
465
+ last_message_orm = self._message_index[self.active_branch_id]
466
+ if last_message_orm.sender_type != 'assistant':
467
+ raise ValueError("Can only regenerate from an assistant's message.")
468
+ parent_id, last_message_id = last_message_orm.parent_id, last_message_orm.id
469
+ self._db_discussion.messages.remove(last_message_orm)
470
+ del self._message_index[last_message_id]
471
+ if self._is_db_backed:
472
+ self._messages_to_delete_from_db.add(last_message_id)
473
+ self.active_branch_id = parent_id
474
+ self.touch()
475
+ return self.chat("", **kwargs)
476
+
477
+ def delete_branch(self, message_id: str):
478
+ if not self._is_db_backed:
479
+ raise NotImplementedError("Branch deletion is only supported for database-backed discussions.")
480
+ if message_id not in self._message_index:
481
+ raise ValueError("Message not found.")
482
+ msg_to_delete = self._session.query(self.db_manager.MessageModel).filter_by(id=message_id).first()
483
+ if msg_to_delete:
484
+ self.active_branch_id = msg_to_delete.parent_id
485
+ self._session.delete(msg_to_delete)
486
+ self.commit()
487
+
488
+ def switch_to_branch(self, message_id: str):
489
+ if message_id not in self._message_index:
490
+ raise ValueError(f"Message ID '{message_id}' not found in the current discussion.")
491
+ self.active_branch_id = message_id
492
+ self.touch()
299
493
 
300
- if current_tokens + msg_tokens > max_allowed_tokens:
301
- break # Stop if adding the next message exceeds the limit
494
+ def format_discussion(self, max_allowed_tokens: int, branch_tip_id: Optional[str] = None) -> str:
495
+ return self.export("lollms_text", branch_tip_id, max_allowed_tokens)
302
496
 
303
- prompt_parts.insert(1 if full_system_prompt else 0, msg_text) # Prepend after system prompt
304
- current_tokens += msg_tokens
497
+ def _get_full_system_prompt(self) -> Optional[str]:
498
+ parts = []
499
+ if self.scratchpad:
500
+ parts.extend(["--- KNOWLEDGE SCRATCHPAD ---", self.scratchpad.strip(), "--- END SCRATCHPAD ---"])
501
+ if self.system_prompt and self.system_prompt.strip():
502
+ parts.append(self.system_prompt.strip())
503
+ return "\n\n".join(parts) if parts else None
504
+
505
+ def export(self, format_type: str, branch_tip_id: Optional[str] = None, max_allowed_tokens: Optional[int] = None) -> Union[List[Dict], str]:
506
+ branch_tip_id = branch_tip_id or self.active_branch_id
507
+ if not branch_tip_id and format_type in ["lollms_text", "openai_chat", "ollama_chat"]:
508
+ return "" if format_type == "lollms_text" else []
509
+ branch, full_system_prompt, participants = self.get_branch(branch_tip_id), self._get_full_system_prompt(), self.participants or {}
510
+
511
+ def get_full_content(msg: LollmsMessage) -> str:
512
+ content_to_use = msg.content
513
+ if self.include_thoughts_in_context and msg.sender_type == 'assistant' and msg.raw_content:
514
+ if self.thought_placeholder:
515
+ content_to_use = re.sub(r"<think>.*?</think>", f"<think>{self.thought_placeholder}</think>", msg.raw_content, flags=re.DOTALL)
516
+ else:
517
+ content_to_use = msg.raw_content
305
518
 
306
- return "".join(prompt_parts).strip()
307
-
308
-
309
- def export(self, format_type: str, branch_tip_id: Optional[str] = None) -> Union[List[Dict], str]:
310
- """
311
- Exports the full, untruncated discussion history in a specific format.
312
- """
313
- if branch_tip_id is None: branch_tip_id = self.active_branch_id
314
- if branch_tip_id is None and not self._get_full_system_prompt(): return "" if format_type in ["lollms_text", "openai_completion"] else []
315
-
316
- branch = self.get_branch(branch_tip_id) if branch_tip_id else []
317
- full_system_prompt = self._get_full_system_prompt()
318
-
319
- if format_type == "openai_chat":
320
- messages = []
321
- if full_system_prompt: messages.append({"role": "system", "content": full_system_prompt})
322
- def openai_image_block(image: Dict[str, str]) -> Dict:
323
- image_url = image['data'] if image['type'] == 'url' else f"data:image/jpeg;base64,{image['data']}"
324
- return {"type": "image_url", "image_url": {"url": image_url, "detail": "auto"}}
325
- for msg in branch:
326
- role = self.participants.get(msg.sender, "user")
519
+ parts = [f"--- Internal Scratchpad ---\n{msg.scratchpad.strip()}\n---"] if msg.scratchpad and msg.scratchpad.strip() else []
520
+ parts.append(content_to_use.strip())
521
+ return "\n".join(parts)
522
+
523
+ if format_type == "lollms_text":
524
+ prompt_parts, current_tokens = [], 0
525
+ if full_system_prompt:
526
+ sys_msg_text = f"!@>system:\n{full_system_prompt}\n"
527
+ sys_tokens = self.lollmsClient.count_tokens(sys_msg_text)
528
+ if max_allowed_tokens is None or sys_tokens <= max_allowed_tokens:
529
+ prompt_parts.append(sys_msg_text)
530
+ current_tokens += sys_tokens
531
+ for msg in reversed(branch):
532
+ sender_str = msg.sender.replace(':', '').replace('!@>', '')
533
+ content = get_full_content(msg)
327
534
  if msg.images:
328
- content_parts = [{"type": "text", "text": msg.content.strip()}] if msg.content.strip() else []
329
- content_parts.extend(openai_image_block(img) for img in msg.images)
535
+ content += f"\n({len(msg.images)} image(s) attached)"
536
+ msg_text = f"!@>{sender_str}:\n{content}\n"
537
+ msg_tokens = self.lollmsClient.count_tokens(msg_text)
538
+ if max_allowed_tokens is not None and current_tokens + msg_tokens > max_allowed_tokens:
539
+ break
540
+ prompt_parts.insert(1 if full_system_prompt else 0, msg_text)
541
+ current_tokens += msg_tokens
542
+ return "".join(prompt_parts).strip()
543
+
544
+ messages = []
545
+ if full_system_prompt:
546
+ messages.append({"role": "system", "content": full_system_prompt})
547
+ for msg in branch:
548
+ role, content, images = participants.get(msg.sender, "user"), get_full_content(msg), msg.images or []
549
+ if format_type == "openai_chat":
550
+ if images:
551
+ content_parts = [{"type": "text", "text": content}] if content else []
552
+ for img in images:
553
+ content_parts.append({"type": "image_url", "image_url": {"url": img['data'] if img['type'] == 'url' else f"data:image/jpeg;base64,{img['data']}", "detail": "auto"}})
330
554
  messages.append({"role": role, "content": content_parts})
331
- else: messages.append({"role": role, "content": msg.content.strip()})
332
- return messages
333
-
334
- elif format_type == "ollama_chat":
335
- messages = []
336
- if full_system_prompt: messages.append({"role": "system", "content": full_system_prompt})
337
- for msg in branch:
338
- role = self.participants.get(msg.sender, "user")
339
- message_dict = {"role": role, "content": msg.content.strip()}
340
- ollama_images = [img['data'] for img in msg.images if img['type'] == 'base64']
341
- if ollama_images: message_dict["images"] = ollama_images
555
+ else:
556
+ messages.append({"role": role, "content": content})
557
+ elif format_type == "ollama_chat":
558
+ message_dict = {"role": role, "content": content}
559
+ base64_images = [img['data'] for img in images if img['type'] == 'base64']
560
+ if base64_images:
561
+ message_dict["images"] = base64_images
342
562
  messages.append(message_dict)
343
- return messages
563
+ else:
564
+ raise ValueError(f"Unsupported export format_type: {format_type}")
565
+ return messages
566
+
567
+ def summarize_and_prune(self, max_tokens: int, preserve_last_n: int = 4):
568
+ branch_tip_id = self.active_branch_id
569
+ if not branch_tip_id:
570
+ return
571
+ current_tokens = self.lollmsClient.count_tokens(self.format_discussion(999999, branch_tip_id))
572
+ if current_tokens <= max_tokens:
573
+ return
574
+ branch = self.get_branch(branch_tip_id)
575
+ if len(branch) <= preserve_last_n:
576
+ return
577
+ messages_to_prune = branch[:-preserve_last_n]
578
+ text_to_summarize = "\n\n".join([f"{m.sender}: {m.content}" for m in messages_to_prune])
579
+ summary_prompt = f"Concisely summarize this conversation excerpt:\n---\n{text_to_summarize}\n---\nSUMMARY:"
580
+ try:
581
+ summary = self.lollmsClient.generate_text(summary_prompt, n_predict=300, temperature=0.1)
582
+ except Exception as e:
583
+ print(f"\n[WARNING] Pruning failed, couldn't generate summary: {e}")
584
+ return
585
+ self.scratchpad = f"{self.scratchpad}\n\n--- Summary of earlier conversation ---\n{summary.strip()}".strip()
586
+ pruned_ids = {msg.id for msg in messages_to_prune}
587
+ if self._is_db_backed:
588
+ self._messages_to_delete_from_db.update(pruned_ids)
589
+ self._db_discussion.messages = [m for m in self._db_discussion.messages if m.id not in pruned_ids]
590
+ else:
591
+ self._db_discussion.messages = [m for m in self._db_discussion.messages if m.id not in pruned_ids]
592
+ self._rebuild_message_index()
593
+ self.touch()
594
+ print(f"\n[INFO] Discussion auto-pruned. {len(messages_to_prune)} messages summarized.")
344
595
 
345
- elif format_type == "lollms_text":
346
- full_prompt_parts = []
347
- if full_system_prompt: full_prompt_parts.append(f"!@>system:\n{full_system_prompt}")
348
- for msg in branch:
349
- sender_str = msg.sender.replace(':', '').replace('!@>', '')
350
- content = msg.content.strip()
351
- if msg.images: content += f"\n({len(msg.images)} image(s) attached)"
352
- full_prompt_parts.append(f"!@>{sender_str}:\n{content}")
353
- return "\n".join(full_prompt_parts)
354
-
355
- elif format_type == "openai_completion":
356
- full_prompt_parts = []
357
- if full_system_prompt: full_prompt_parts.append(f"System:\n{full_system_prompt}")
358
- for msg in branch:
359
- role_label = self.participants.get(msg.sender, "user").capitalize()
360
- content = msg.content.strip()
361
- if msg.images: content += f"\n({len(msg.images)} image(s) attached)"
362
- full_prompt_parts.append(f"{role_label}:\n{content}")
363
- return "\n\n".join(full_prompt_parts)
364
-
365
- else: raise ValueError(f"Unsupported export format_type: {format_type}")
366
-
367
-
368
- if __name__ == "__main__":
369
- class MockBinding:
370
- def tokenize(self, text: str) -> List[int]: return text.split()
371
- class MockLollmsClient:
372
- def __init__(self): self.binding = MockBinding()
373
- def generate(self, prompt: str, max_new_tokens: int, temperature: float) -> str: return "This is a generated summary."
374
-
375
- print("--- Initializing Mock Client and Discussion ---")
376
- mock_client = MockLollmsClient()
377
- discussion = LollmsDiscussion(mock_client)
378
- discussion.set_participants({"User": "user", "Project Lead": "assistant"})
379
- discussion.set_system_prompt("This is a formal discussion about Project Phoenix.")
380
- discussion.set_scratchpad("Initial State: Project Phoenix is in the planning phase.")
381
-
382
- print("\n--- Creating a long discussion history ---")
383
- parent_id = None
384
- long_text = "extra text to increase token count"
385
- for i in range(10):
386
- user_msg = f"Message #{i*2+1}: Update on task {i+1}? {long_text}"
387
- user_id = discussion.add_message("User", "user", user_msg, parent_id=parent_id)
388
- assistant_msg = f"Message #{i*2+2}: Task {i+1} status is blocked. {long_text}"
389
- assistant_id = discussion.add_message("Project Lead", "assistant", assistant_msg, parent_id=user_id)
390
- parent_id = assistant_id
391
-
392
- initial_tokens = len(mock_client.binding.tokenize(discussion.export("lollms_text")))
393
- print(f"Initial message count: {len(discussion.messages)}, Initial tokens: {initial_tokens}")
394
-
395
- print("\n--- Testing Pruning ---")
396
- prune_result = discussion.summarize_and_prune(max_tokens=200, preserve_last_n=4)
397
- if prune_result.get("pruned"):
398
- print("✅ Pruning was successful!")
399
- assert "Summary" in discussion.get_scratchpad()
400
- else: print(f"❌ Pruning failed: {prune_result.get('reason')}")
401
-
402
- print("\n--- Testing format_discussion (Instruct Model Format) ---")
403
- truncated_prompt = discussion.format_discussion(max_allowed_tokens=80)
404
- truncated_tokens = len(mock_client.binding.tokenize(truncated_prompt))
405
- print(f"Truncated prompt tokens: {truncated_tokens}")
406
- print("Truncated Prompt:\n" + "="*20 + f"\n{truncated_prompt}\n" + "="*20)
407
-
408
- # Verification
409
- assert truncated_tokens <= 80
410
- # Check that it contains the newest message that fits
411
- assert "Message #19" in truncated_prompt or "Message #20" in truncated_prompt
412
- print("✅ format_discussion correctly truncated the prompt.")
596
+ def to_dict(self):
597
+ return {
598
+ "id": self.id, "system_prompt": self.system_prompt, "participants": self.participants,
599
+ "active_branch_id": self.active_branch_id, "metadata": self.metadata, "scratchpad": self.scratchpad,
600
+ "messages": [{ 'id': m.id, 'parent_id': m.parent_id, 'discussion_id': m.discussion_id, 'sender': m.sender,
601
+ 'sender_type': m.sender_type, 'content': m.content, 'scratchpad': m.scratchpad, 'images': m.images,
602
+ 'created_at': m.created_at.isoformat(), 'metadata': m.metadata } for m in self.messages],
603
+ "created_at": self.created_at.isoformat() if self.created_at else None,
604
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None
605
+ }
606
+
607
+ def load_from_dict(self, data: Dict):
608
+ self._create_in_memory_proxy(id=data.get("id"))
609
+ self.system_prompt, self.participants = data.get("system_prompt"), data.get("participants", {})
610
+ self.active_branch_id, self.metadata = data.get("active_branch_id"), data.get("metadata", {})
611
+ self.scratchpad = data.get("scratchpad", "")
612
+ for msg_data in data.get("messages", []):
613
+ if 'created_at' in msg_data and isinstance(msg_data['created_at'], str):
614
+ try:
615
+ msg_data['created_at'] = datetime.fromisoformat(msg_data['created_at'])
616
+ except ValueError:
617
+ msg_data['created_at'] = datetime.utcnow()
618
+ self.add_message(**msg_data)
619
+ self.created_at = datetime.fromisoformat(data['created_at']) if data.get('created_at') else datetime.utcnow()
620
+ self.updated_at = datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else self.created_at
621
+
622
+ @staticmethod
623
+ def migrate(lollms_client: 'LollmsClient', db_manager: LollmsDataManager, folder_path: Union[str, Path]):
624
+ folder = Path(folder_path)
625
+ if not folder.is_dir():
626
+ print(f"Error: Path '{folder}' is not a valid directory.")
627
+ return
628
+ print(f"\n--- Starting Migration from '{folder}' ---")
629
+ files = list(folder.glob("*.json")) + list(folder.glob("*.yaml"))
630
+ with db_manager.get_session() as session:
631
+ valid_disc_keys = {c.name for c in db_manager.DiscussionModel.__table__.columns}
632
+ valid_msg_keys = {c.name for c in db_manager.MessageModel.__table__.columns}
633
+ for i, file_path in enumerate(files):
634
+ print(f"Migrating file {i+1}/{len(files)}: {file_path.name} ... ", end="")
635
+ try:
636
+ data = yaml.safe_load(file_path.read_text(encoding='utf-8'))
637
+ discussion_id = data.get("id", str(uuid.uuid4()))
638
+ if session.query(db_manager.DiscussionModel).filter_by(id=discussion_id).first():
639
+ print("SKIPPED (already exists)")
640
+ continue
641
+ discussion_data = data.copy()
642
+ if 'metadata' in discussion_data:
643
+ discussion_data['discussion_metadata'] = discussion_data.pop('metadata')
644
+ for key in ['created_at', 'updated_at']:
645
+ if key in discussion_data and isinstance(discussion_data[key], str):
646
+ discussion_data[key] = datetime.fromisoformat(discussion_data[key])
647
+ db_discussion = db_manager.DiscussionModel(**{k: v for k, v in discussion_data.items() if k in valid_disc_keys})
648
+ session.add(db_discussion)
649
+ for msg_data in data.get("messages", []):
650
+ msg_data['discussion_id'] = db_discussion.id
651
+ if 'metadata' in msg_data:
652
+ msg_data['message_metadata'] = msg_data.pop('metadata')
653
+ if 'created_at' in msg_data and isinstance(msg_data['created_at'], str):
654
+ msg_data['created_at'] = datetime.fromisoformat(msg_data['created_at'])
655
+ msg_orm = db_manager.MessageModel(**{k: v for k, v in msg_data.items() if k in valid_msg_keys})
656
+ session.add(msg_orm)
657
+ session.flush()
658
+ print("OK")
659
+ except Exception as e:
660
+ print(f"FAILED. Error: {e}")
661
+ session.rollback()
662
+ continue
663
+ session.commit()
664
+ print("--- Migration Finished ---")