mirror of
https://github.com/imartinez/privateGPT.git
synced 2026-07-17 01:48:03 +00:00
369 lines
13 KiB
Python
369 lines
13 KiB
Python
import enum
|
|
import json
|
|
from collections.abc import Callable
|
|
from typing import Any, Literal
|
|
|
|
from llama_index.core.base.llms.types import ChatMessage, MessageRole
|
|
from llama_index.core.bridge.pydantic import Field, model_validator
|
|
from llama_index.core.llms.llm import LLM
|
|
from llama_index.core.memory.types import (
|
|
DEFAULT_CHAT_STORE_KEY,
|
|
BaseChatStoreMemory,
|
|
)
|
|
from llama_index.core.storage.chat_store import BaseChatStore, SimpleChatStore
|
|
|
|
from private_gpt.components.llm.llm_helper import TokenizerFn
|
|
from private_gpt.utils.tokens import async_tokenizer
|
|
|
|
DEFAULT_TOKEN_LIMIT_RATIO = 0.75
|
|
DEFAULT_TOKEN_LIMIT = 3000
|
|
|
|
|
|
class TrimStrategy(enum.StrEnum):
|
|
"""Strategy for trimming messages."""
|
|
|
|
FIRST = "first" # Keep first messages up to token limit
|
|
LAST = "last" # Keep last messages up to token limit
|
|
|
|
|
|
def _default_text_splitter(text: str) -> list[str]:
|
|
"""Default text splitter that splits on newlines."""
|
|
splits = text.split("\n")
|
|
return [s + "\n" for s in splits[:-1]] + splits[-1:]
|
|
|
|
|
|
def _is_message_type(
|
|
message: ChatMessage, message_types: MessageRole | list[MessageRole]
|
|
) -> bool:
|
|
"""Check if message matches one of the specified types."""
|
|
if isinstance(message_types, MessageRole):
|
|
message_types = [message_types]
|
|
return message.role in message_types
|
|
|
|
|
|
class TrimmingMemory(BaseChatStoreMemory):
|
|
"""Advanced buffer for storing and managing chat history with trimming capabilities.
|
|
|
|
This memory buffer extends the basic ChatMemoryBuffer with advanced features:
|
|
- Message trimming with configurable strategies
|
|
- System message preservation
|
|
- Partial message support
|
|
- Flexible token counting
|
|
"""
|
|
|
|
token_limit: int
|
|
trim_strategy: TrimStrategy = TrimStrategy.LAST
|
|
include_system: bool = True
|
|
allow_partial: bool = False
|
|
start_on: MessageRole | list[MessageRole] | None = None
|
|
end_on: MessageRole | list[MessageRole] | None = None
|
|
text_splitter: Callable[[str], list[str]] = Field(
|
|
default_factory=lambda: _default_text_splitter,
|
|
exclude=True,
|
|
)
|
|
tokenizer_fn: TokenizerFn = Field(
|
|
exclude=True,
|
|
)
|
|
|
|
@classmethod
|
|
def class_name(cls) -> str:
|
|
"""Get class name."""
|
|
return "TrimmingMemory"
|
|
|
|
@model_validator(mode="before")
|
|
@classmethod
|
|
def validate_memory(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
"""Validate memory configuration."""
|
|
# Validate token limit
|
|
token_limit = values.get("token_limit", -1)
|
|
if token_limit < 1:
|
|
raise ValueError("Token limit must be set and greater than 0.")
|
|
|
|
# Validate tokenizer
|
|
tokenizer_fn = values.get("tokenizer_fn")
|
|
if tokenizer_fn is None:
|
|
# TODO: Replace with a default tokenizer function
|
|
raise ValueError("tokenizer_fn must be provided.")
|
|
|
|
# Validate text splitter
|
|
text_splitter = values.get("text_splitter")
|
|
if text_splitter is None:
|
|
values["text_splitter"] = _default_text_splitter
|
|
|
|
# Validate strategy-specific constraints
|
|
trim_strategy = values.get("trim_strategy", TrimStrategy.LAST)
|
|
start_on = values.get("start_on")
|
|
include_system = values.get("include_system", True)
|
|
|
|
if start_on and trim_strategy == TrimStrategy.FIRST:
|
|
raise ValueError("start_on can only be used with 'last' strategy")
|
|
|
|
if include_system and trim_strategy == TrimStrategy.FIRST:
|
|
raise ValueError("include_system can only be used with 'last' strategy")
|
|
|
|
return values
|
|
|
|
@classmethod
|
|
def from_defaults(
|
|
cls,
|
|
chat_history: list[ChatMessage] | None = None,
|
|
llm: LLM | None = None,
|
|
chat_store: BaseChatStore | None = None,
|
|
chat_store_key: str = DEFAULT_CHAT_STORE_KEY,
|
|
token_limit: int | None = None,
|
|
trim_strategy: TrimStrategy = TrimStrategy.LAST,
|
|
include_system: bool = True,
|
|
allow_partial: bool = False,
|
|
start_on: MessageRole | list[MessageRole] | None = None,
|
|
end_on: MessageRole | list[MessageRole] | None = None,
|
|
tokenizer_fn: TokenizerFn | None = None,
|
|
text_splitter: Callable[[str], list[str]] | None = None,
|
|
**kwargs: Any,
|
|
) -> "TrimmingMemory":
|
|
"""Create an advanced chat memory buffer from an LLM."""
|
|
if kwargs:
|
|
raise ValueError(f"Unexpected kwargs: {kwargs}")
|
|
|
|
if llm is not None:
|
|
context_window = llm.metadata.context_window
|
|
token_limit = token_limit or int(context_window * DEFAULT_TOKEN_LIMIT_RATIO)
|
|
elif token_limit is None:
|
|
token_limit = DEFAULT_TOKEN_LIMIT
|
|
|
|
if chat_history is not None:
|
|
chat_store = chat_store or SimpleChatStore()
|
|
chat_store.set_messages(chat_store_key, chat_history)
|
|
|
|
return cls(
|
|
token_limit=token_limit,
|
|
trim_strategy=trim_strategy,
|
|
include_system=include_system,
|
|
allow_partial=allow_partial,
|
|
start_on=start_on,
|
|
end_on=end_on,
|
|
tokenizer_fn=tokenizer_fn,
|
|
text_splitter=text_splitter or _default_text_splitter,
|
|
chat_store=chat_store or SimpleChatStore(),
|
|
chat_store_key=chat_store_key,
|
|
)
|
|
|
|
async def aget(
|
|
self, input: str | None = None, initial_token_count: int = 0, **kwargs: Any
|
|
) -> list[ChatMessage]:
|
|
"""Get trimmed chat history based on configured strategy."""
|
|
chat_history = await self.aget_all()
|
|
|
|
if not chat_history:
|
|
return []
|
|
|
|
if initial_token_count > self.token_limit:
|
|
raise ValueError("Initial token count exceeds token limit")
|
|
|
|
max_tokens = self.token_limit - initial_token_count
|
|
|
|
if self.trim_strategy == TrimStrategy.FIRST:
|
|
return await self._trim_first_max_tokens(chat_history, max_tokens)
|
|
else:
|
|
return await self._trim_last_max_tokens(chat_history, max_tokens)
|
|
|
|
async def _trim_first_max_tokens(
|
|
self, messages: list[ChatMessage], max_tokens: int
|
|
) -> list[ChatMessage]:
|
|
"""Keep the first messages up to the token limit."""
|
|
if not messages:
|
|
return messages
|
|
|
|
# Find the maximum number of messages we can include
|
|
idx = 0
|
|
for i in range(len(messages)):
|
|
current_messages = messages[: len(messages) - i] if i else messages
|
|
if await self._token_count_for_messages(current_messages) <= max_tokens:
|
|
idx = len(messages) - i
|
|
break
|
|
|
|
# Handle partial messages if allowed
|
|
if self.allow_partial and idx < len(messages):
|
|
idx = await self._try_include_partial_message(
|
|
messages, idx, max_tokens, "first"
|
|
)
|
|
|
|
# Apply end_on constraint
|
|
if self.end_on:
|
|
while idx > 0 and not _is_message_type(messages[idx - 1], self.end_on):
|
|
idx -= 1
|
|
|
|
return messages[:idx]
|
|
|
|
async def _trim_last_max_tokens(
|
|
self, messages: list[ChatMessage], max_tokens: int
|
|
) -> list[ChatMessage]:
|
|
"""Keep the last messages up to the token limit."""
|
|
if not messages:
|
|
return []
|
|
|
|
# Handle end_on constraint first
|
|
working_messages = messages[:]
|
|
if self.end_on:
|
|
while working_messages and not _is_message_type(
|
|
working_messages[-1], self.end_on
|
|
):
|
|
working_messages.pop()
|
|
|
|
# Check if we need to preserve system message
|
|
has_system = (
|
|
self.include_system
|
|
and working_messages
|
|
and working_messages[0].role == MessageRole.SYSTEM
|
|
)
|
|
|
|
if has_system:
|
|
system_msg = working_messages[0]
|
|
remaining_messages = working_messages[1:]
|
|
system_tokens = await self._token_count_for_messages([system_msg])
|
|
available_tokens = max_tokens - system_tokens
|
|
|
|
if available_tokens <= 0:
|
|
return []
|
|
|
|
# Trim the non-system messages
|
|
trimmed_remaining = await self._trim_messages_from_end(
|
|
remaining_messages, available_tokens
|
|
)
|
|
|
|
result = [system_msg, *trimmed_remaining]
|
|
else:
|
|
result = await self._trim_messages_from_end(working_messages, max_tokens)
|
|
|
|
# Apply start_on constraint
|
|
if self.start_on and result:
|
|
start_idx = 0
|
|
system_offset = 1 if has_system else 0
|
|
|
|
# Find first occurrence of start_on message type
|
|
for i in range(system_offset, len(result)):
|
|
if _is_message_type(result[i], self.start_on):
|
|
start_idx = i
|
|
break
|
|
|
|
if has_system and start_idx > 0:
|
|
result = [result[0], *result[start_idx:]]
|
|
elif not has_system:
|
|
result = result[start_idx:]
|
|
|
|
return result
|
|
|
|
async def _trim_messages_from_end(
|
|
self, messages: list[ChatMessage], max_tokens: int
|
|
) -> list[ChatMessage]:
|
|
"""Trim messages from the end to fit within token limit."""
|
|
if not messages:
|
|
return messages
|
|
|
|
# Start from the end and work backwards
|
|
for i in range(len(messages)):
|
|
current_messages = messages[-(len(messages) - i) :] if i else messages
|
|
if await self._token_count_for_messages(current_messages) <= max_tokens:
|
|
idx = len(messages) - i
|
|
break
|
|
else:
|
|
idx = 0
|
|
|
|
# Handle partial messages if allowed
|
|
if self.allow_partial and idx > 0:
|
|
idx = await self._try_include_partial_message(
|
|
messages, idx - 1, max_tokens, "last"
|
|
)
|
|
|
|
# Ensure we don't start with assistant or tool messages
|
|
final_messages = messages[-(len(messages) - idx) :] if idx else []
|
|
while final_messages and final_messages[0].role in (
|
|
MessageRole.ASSISTANT,
|
|
MessageRole.TOOL,
|
|
):
|
|
final_messages = final_messages[1:]
|
|
|
|
return final_messages
|
|
|
|
async def _try_include_partial_message(
|
|
self,
|
|
messages: list[ChatMessage],
|
|
idx: int,
|
|
max_tokens: int,
|
|
direction: Literal["first", "last"],
|
|
) -> int:
|
|
"""Try to include a partial message if allow_partial is True."""
|
|
if idx >= len(messages):
|
|
return idx
|
|
|
|
excluded = messages[idx].model_copy(deep=True)
|
|
|
|
# Only handle string content for now
|
|
if isinstance(excluded.content, str):
|
|
text = excluded.content
|
|
split_texts = self.text_splitter(text)
|
|
|
|
if direction == "last":
|
|
split_texts = list(reversed(split_texts))
|
|
|
|
# Try progressively smaller portions
|
|
for i in range(1, len(split_texts)):
|
|
partial_texts = split_texts[:-i]
|
|
partial_content = "".join(partial_texts)
|
|
|
|
if direction == "last":
|
|
partial_content = "".join(reversed(partial_texts))
|
|
|
|
excluded.content = partial_content
|
|
|
|
test_messages = (
|
|
[*messages[:idx], excluded]
|
|
if direction == "first"
|
|
else [*messages[idx:], excluded]
|
|
)
|
|
|
|
if await self._token_count_for_messages(test_messages) <= max_tokens:
|
|
messages[idx] = excluded
|
|
return idx + 1
|
|
|
|
return idx
|
|
|
|
async def _token_count_for_messages(self, messages: list[ChatMessage]) -> int:
|
|
"""Count tokens for a list of messages."""
|
|
if not messages:
|
|
return 0
|
|
|
|
# Convert messages to string representation for token counting
|
|
msg_str = " ".join(str(m.content) for m in messages)
|
|
return len(await async_tokenizer(msg_str, tokenizer_fn=self.tokenizer_fn))
|
|
|
|
def to_string(self) -> str:
|
|
"""Convert memory to string."""
|
|
return self.json()
|
|
|
|
@classmethod
|
|
def from_string(cls, json_str: str) -> "TrimmingMemory":
|
|
"""Create memory from string."""
|
|
dict_obj = json.loads(json_str)
|
|
return cls.from_dict(dict_obj)
|
|
|
|
def to_dict(self, **kwargs: Any) -> dict[str, Any]:
|
|
"""Convert memory to dict."""
|
|
return self.dict()
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> "TrimmingMemory":
|
|
"""Create memory from dict."""
|
|
from llama_index.core.storage.chat_store.loading import load_chat_store
|
|
|
|
# Handle backwards compatibility
|
|
if "chat_history" in data:
|
|
chat_history = data.pop("chat_history")
|
|
simple_store = SimpleChatStore(store={DEFAULT_CHAT_STORE_KEY: chat_history})
|
|
data["chat_store"] = simple_store
|
|
elif "chat_store" in data:
|
|
chat_store_dict = data.pop("chat_store")
|
|
chat_store = load_chat_store(chat_store_dict)
|
|
data["chat_store"] = chat_store
|
|
|
|
return cls(**data)
|