Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/config.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions docs/config.puml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class "CORSConfiguration" as src.models.config.CORSConfiguration {
class "Configuration" as src.models.config.Configuration {
authentication : Optional[AuthenticationConfiguration]
authorization : Optional[AuthorizationConfiguration]
conversation_cache : Optional[ConversationCache]
conversation_cache : Optional[ConversationCacheConfiguration]
customization : Optional[Customization]
database : Optional[DatabaseConfiguration]
inference : Optional[InferenceConfiguration]
Expand All @@ -43,7 +43,7 @@ class "Configuration" as src.models.config.Configuration {
class "ConfigurationBase" as src.models.config.ConfigurationBase {
model_config
}
class "ConversationCache" as src.models.config.ConversationCache {
class "ConversationCacheConfiguration" as src.models.config.ConversationCacheConfiguration {
memory : Optional[InMemoryCacheConfig]
postgres : Optional[PostgreSQLDatabaseConfiguration]
sqlite : Optional[SQLiteDatabaseConfiguration]
Expand Down Expand Up @@ -157,7 +157,7 @@ src.models.config.AuthenticationConfiguration --|> src.models.config.Configurati
src.models.config.AuthorizationConfiguration --|> src.models.config.ConfigurationBase
src.models.config.CORSConfiguration --|> src.models.config.ConfigurationBase
src.models.config.Configuration --|> src.models.config.ConfigurationBase
src.models.config.ConversationCache --|> src.models.config.ConfigurationBase
src.models.config.ConversationCacheConfiguration --|> src.models.config.ConfigurationBase
src.models.config.Customization --|> src.models.config.ConfigurationBase
src.models.config.DatabaseConfiguration --|> src.models.config.ConfigurationBase
src.models.config.InMemoryCacheConfig --|> src.models.config.ConfigurationBase
Expand Down
534 changes: 267 additions & 267 deletions docs/config.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/cache/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Various cache implementations."""
110 changes: 110 additions & 0 deletions src/cache/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Abstract class that is parent for all cache implementations."""

from abc import ABC, abstractmethod

from models.cache_entry import CacheEntry
from utils.suid import check_suid


class Cache(ABC):
"""Abstract class that is parent for all cache implementations.

Cache entries are identified by compound key that consists of
user ID and conversation ID. Application logic must ensure that
user will be able to store and retrieve values that have the
correct user ID only. This means that user won't be able to
read or modify other users conversations.
"""

# separator between parts of compond key
COMPOUND_KEY_SEPARATOR = ":"

@staticmethod
def _check_user_id(user_id: str, skip_user_id_check: bool) -> None:
"""Check if given user ID is valid."""
if skip_user_id_check:
return
if not check_suid(user_id):
raise ValueError(f"Invalid user ID {user_id}")

@staticmethod
def _check_conversation_id(conversation_id: str) -> None:
"""Check if given conversation ID is a valid UUID (including optional dashes)."""
if not check_suid(conversation_id):
raise ValueError(f"Invalid conversation ID {conversation_id}")

@staticmethod
def construct_key(
user_id: str, conversation_id: str, skip_user_id_check: bool
) -> str:
"""Construct key to cache."""
Cache._check_user_id(user_id, skip_user_id_check)
Cache._check_conversation_id(conversation_id)
return f"{user_id}{Cache.COMPOUND_KEY_SEPARATOR}{conversation_id}"

@abstractmethod
def get(
self, user_id: str, conversation_id: str, skip_user_id_check: bool
) -> list[CacheEntry]:
"""Abstract method to retrieve a value from the cache.

Args:
user_id: User identification.
conversation_id: Conversation ID unique for given user.
skip_user_id_check: Skip user_id suid check.

Returns:
The value (CacheEntry(s)) associated with the key, or None if not found.
"""

@abstractmethod
def insert_or_append(
self,
user_id: str,
conversation_id: str,
cache_entry: CacheEntry,
skip_user_id_check: bool,
) -> None:
"""Abstract method to store a value in the cache.

Args:
user_id: User identification.
conversation_id: Conversation ID unique for given user.
cache_entry: The value to store.
skip_user_id_check: Skip user_id suid check.
"""

@abstractmethod
def delete(
self, user_id: str, conversation_id: str, skip_user_id_check: bool
) -> bool:
"""Delete all entries for a given conversation.

Args:
user_id: User identification.
conversation_id: Conversation ID unique for given user.
skip_user_id_check: Skip user_id suid check.

Returns:
bool: True if entries were deleted, False if key wasn't found.
"""

@abstractmethod
def list(self, user_id: str, skip_user_id_check: bool) -> list[str]:
"""List all conversations for a given user_id.

Args:
user_id: User identification.
skip_user_id_check: Skip user_id suid check.

Returns:
A list of conversation ids from the cache
"""

@abstractmethod
def ready(self) -> bool:
"""Check if the cache is ready.

Returns:
True if the cache is ready, False otherwise.
"""
5 changes: 5 additions & 0 deletions src/cache/cache_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Any exception that can occur during cache operations."""


class CacheError(Exception):
"""Any exception that can occur during cache operations."""
35 changes: 35 additions & 0 deletions src/cache/cache_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Cache factory class."""

import constants
from models.config import ConversationCacheConfiguration
from cache.cache import Cache
from log import get_logger

logger = get_logger("cache.cache_factory")


# pylint: disable=R0903
class CacheFactory:
"""Cache factory class."""

@staticmethod
def conversation_cache(config: ConversationCacheConfiguration) -> Cache | None:
"""Create an instance of Cache based on loaded configuration.
Returns:
An instance of `Cache` (either `PostgresCache` or `InMemoryCache`).
"""
logger.info("Creating cache instance of type %s", config.type)
match config.type:
case constants.CACHE_TYPE_MEMORY:
return None
case constants.CACHE_TYPE_SQLITE:
return None
case constants.CACHE_TYPE_POSTGRES:
return None
case _:
raise ValueError(
f"Invalid cache type: {config.type}. "
f"Use '{constants.CACHE_TYPE_POSTGRES}' '{constants.CACHE_TYPE_SQLITE}' or "
f"'{constants.CACHE_TYPE_MEMORY}' options."
)
Comment on lines +15 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Handle None/disabled cache config and fix docstring/options text

Factory currently assumes config is non‑None and will crash when configuration.conversation_cache is None or when type is None (a valid state per validator). Also docstring mentions only Postgres/InMemory while SQLite is supported by the type.

 class CacheFactory:
@@
-    def conversation_cache(config: ConversationCacheConfiguration) -> Cache | None:
-        """Create an instance of Cache based on loaded configuration.
-
-        Returns:
-            An instance of `Cache` (either `PostgresCache` or `InMemoryCache`).
-        """
-        logger.info("Creating cache instance of type %s", config.type)
-        match config.type:
+    def conversation_cache(config: ConversationCacheConfiguration | None) -> Cache | None:
+        """Create an instance of Cache based on loaded configuration.
+
+        Returns:
+            A Cache instance, or None when cache is not configured/disabled or not yet implemented.
+        """
+        if config is None or config.type is None:
+            logger.info("Conversation cache disabled or not configured")
+            return None
+
+        logger.info("Creating cache instance of type %s", config.type)
+        match config.type:
             case constants.CACHE_TYPE_MEMORY:
                 return None
             case constants.CACHE_TYPE_SQLITE:
                 return None
             case constants.CACHE_TYPE_POSTGRES:
                 return None
             case _:
                 raise ValueError(
-                    f"Invalid cache type: {config.type}. "
-                    f"Use '{constants.CACHE_TYPE_POSTGRES}' '{constants.CACHE_TYPE_SQLITE}' or "
-                    f"'{constants.CACHE_TYPE_MEMORY}' options."
+                    f"Invalid cache type: {config.type}. "
+                    f"Use '{constants.CACHE_TYPE_POSTGRES}', '{constants.CACHE_TYPE_SQLITE}', or "
+                    f"'{constants.CACHE_TYPE_MEMORY}'."
                 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@staticmethod
def conversation_cache(config: ConversationCacheConfiguration) -> Cache | None:
"""Create an instance of Cache based on loaded configuration.
Returns:
An instance of `Cache` (either `PostgresCache` or `InMemoryCache`).
"""
logger.info("Creating cache instance of type %s", config.type)
match config.type:
case constants.CACHE_TYPE_MEMORY:
return None
case constants.CACHE_TYPE_SQLITE:
return None
case constants.CACHE_TYPE_POSTGRES:
return None
case _:
raise ValueError(
f"Invalid cache type: {config.type}. "
f"Use '{constants.CACHE_TYPE_POSTGRES}' '{constants.CACHE_TYPE_SQLITE}' or "
f"'{constants.CACHE_TYPE_MEMORY}' options."
)
@staticmethod
def conversation_cache(config: ConversationCacheConfiguration | None) -> Cache | None:
"""Create an instance of Cache based on loaded configuration.
Returns:
A Cache instance, or None when cache is not configured/disabled or not yet implemented.
"""
if config is None or config.type is None:
logger.info("Conversation cache disabled or not configured")
return None
logger.info("Creating cache instance of type %s", config.type)
match config.type:
case constants.CACHE_TYPE_MEMORY:
return None
case constants.CACHE_TYPE_SQLITE:
return None
case constants.CACHE_TYPE_POSTGRES:
return None
case _:
raise ValueError(
f"Invalid cache type: {config.type}. "
f"Use '{constants.CACHE_TYPE_POSTGRES}', '{constants.CACHE_TYPE_SQLITE}', or "
f"'{constants.CACHE_TYPE_MEMORY}'."
)
🤖 Prompt for AI Agents
In src/cache/cache_factory.py around lines 15 to 35, the factory assumes a
non-None config and a non-None config.type and the docstring only lists
Postgres/InMemory; change the function to first handle a None/disabled config by
returning None if config is None or config.type is falsy, then use the existing
match on config.type to return the appropriate Cache implementations (or None
for in-memory/disabled), and update the docstring and the thrown ValueError
message to list all supported options including SQLite, Postgres and InMemory so
the documentation and error text match actual supported types.

17 changes: 15 additions & 2 deletions src/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@
AuthenticationConfiguration,
InferenceConfiguration,
DatabaseConfiguration,
ConversationCache,
ConversationCacheConfiguration,
)

from cache.cache import Cache
from cache.cache_factory import CacheFactory


logger = logging.getLogger(__name__)

Expand All @@ -44,6 +47,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> "AppConfig":
def __init__(self) -> None:
"""Initialize the class instance."""
self._configuration: Optional[Configuration] = None
self._conversation_cache: Optional[Cache] = None

def load_configuration(self, filename: str) -> None:
"""Load configuration from YAML file."""
Expand Down Expand Up @@ -126,7 +130,7 @@ def inference(self) -> InferenceConfiguration:
return self._configuration.inference

@property
def conversation_cache(self) -> ConversationCache:
def conversation_cache_configuration(self) -> ConversationCacheConfiguration:
"""Return conversation cache configuration."""
if self._configuration is None:
raise LogicError("logic error: configuration is not loaded")
Expand All @@ -139,5 +143,14 @@ def database_configuration(self) -> DatabaseConfiguration:
raise LogicError("logic error: configuration is not loaded")
return self._configuration.database

@property
def conversation_cache(self) -> Cache | None:
"""Return the conversation cache."""
if self._conversation_cache is None and self._configuration is not None:
self._conversation_cache = CacheFactory.conversation_cache(
self._configuration.conversation_cache
)
return self._conversation_cache
Comment on lines +146 to +153
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Guard for unloaded config and None cache config before calling factory

Avoids passing None to the factory and keeps behavior consistent with other accessors.

 @property
 def conversation_cache(self) -> Cache | None:
     """Return the conversation cache."""
-    if self._conversation_cache is None and self._configuration is not None:
-        self._conversation_cache = CacheFactory.conversation_cache(
-            self._configuration.conversation_cache
-        )
-    return self._conversation_cache
+    if self._configuration is None:
+        raise LogicError("logic error: configuration is not loaded")
+    if self._conversation_cache is None:
+        cfg = self._configuration.conversation_cache
+        if cfg is None or cfg.type is None:
+            self._conversation_cache = None
+        else:
+            self._conversation_cache = CacheFactory.conversation_cache(cfg)
+    return self._conversation_cache
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@property
def conversation_cache(self) -> Cache | None:
"""Return the conversation cache."""
if self._conversation_cache is None and self._configuration is not None:
self._conversation_cache = CacheFactory.conversation_cache(
self._configuration.conversation_cache
)
return self._conversation_cache
@property
def conversation_cache(self) -> Cache | None:
"""Return the conversation cache."""
if self._configuration is None:
raise LogicError("logic error: configuration is not loaded")
if self._conversation_cache is None:
cfg = self._configuration.conversation_cache
if cfg is None or cfg.type is None:
self._conversation_cache = None
else:
self._conversation_cache = CacheFactory.conversation_cache(cfg)
return self._conversation_cache
🤖 Prompt for AI Agents
In src/configuration.py around lines 146 to 153, the property currently calls
CacheFactory.conversation_cache even when self._configuration is None or when
self._configuration.conversation_cache is None; add a guard so the factory is
only called if self._conversation_cache is None AND self._configuration is not
None AND self._configuration.conversation_cache is not None, otherwise leave
self._conversation_cache as None and return it; mirror the same defensive
pattern used by the other accessors to avoid passing None into the factory.



configuration: AppConfig = AppConfig()
19 changes: 19 additions & 0 deletions src/models/cache_entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Model for conversation history cache entry."""

from pydantic import BaseModel


class CacheEntry(BaseModel):
"""Model representing a cache entry.

Attributes:
query: The query string
response: The response string
provider: Provider identification
model: Model identification
"""

query: str
response: str
provider: str
model: str
6 changes: 4 additions & 2 deletions src/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ def check_default_model_and_provider(self) -> Self:
return self


class ConversationCache(ConfigurationBase):
class ConversationCacheConfiguration(ConfigurationBase):
"""Conversation cache configuration."""

type: Literal["memory", "sqlite", "postgres"] | None = None
Expand Down Expand Up @@ -542,7 +542,9 @@ class Configuration(ConfigurationBase):
authorization: Optional[AuthorizationConfiguration] = None
customization: Optional[Customization] = None
inference: InferenceConfiguration = Field(default_factory=InferenceConfiguration)
conversation_cache: ConversationCache = Field(default_factory=ConversationCache)
conversation_cache: ConversationCacheConfiguration = Field(
default_factory=ConversationCacheConfiguration
)

def dump(self, filename: str = "configuration.json") -> None:
"""Dump actual configuration into JSON file."""
Expand Down
Loading
Loading