Skip to content
Merged

1.9.2 #1093

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a77634f
Added CCAT_QDRANT_CLIENT_TIMEOUT environment parameter to configure Q…
primax79 Apr 9, 2025
11facd2
Fix Qdrant collection alias creation
primax79 Apr 9, 2025
3998225
Merge pull request #1061 from primax79/qdrant_timeout
pieroit Apr 11, 2025
99016e5
Merge pull request #1062 from primax79/fix_alias
pieroit Apr 14, 2025
361c98b
Improves plugin validation and handles duplicates
primax79 Apr 16, 2025
14bbe6a
Fixed the log message and removed the url == plugin_url check
primax79 Apr 16, 2025
4462ce7
Merge pull request #1068 from primax79/fix_plugin_url
pieroit Apr 27, 2025
3ebc9ef
Fix escape characters in utils.parse_json
davidebizzocchi Apr 29, 2025
3366ab4
Levenshtein distance without langchain
Pingdred May 1, 2025
d0df77f
Merge pull request #1070 from davidebizzocchi/develop-fix-escape
pieroit May 2, 2025
8c6238e
Merge pull request #1072 from Pingdred/levenshtein
pieroit May 2, 2025
db6eb81
Use official langchain cohere
Pingdred May 2, 2025
ac310bc
Merge pull request #1073 from Pingdred/develop
pieroit May 2, 2025
4385e4e
remove the comment to fix the bug described here https://github.com/c…
luca-piccinelli May 5, 2025
fae3c6e
Add a try/finally to clean up the temporary directory used for the te…
luca-piccinelli May 5, 2025
d59667e
add a test to avoid to re-introduce the bug
luca-piccinelli May 5, 2025
66d2a6e
removed unused import, as suggested from the linter
luca-piccinelli May 5, 2025
3645d42
Merge pull request #1076 from lucapiccinelli/develop
pieroit May 12, 2025
e547050
Update pull_request_template.md
pieroit Jun 27, 2025
f3cdb52
improved classify prompt
lucagobbi Jun 28, 2025
b678a08
Merge pull request #1092 from lucagobbi/develop
pieroit Jun 28, 2025
4c2e326
Update pyproject.toml
pieroit Jun 30, 2025
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
14 changes: 3 additions & 11 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
# Description

Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.

Related to issue #(issue)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context.

# Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I submitted my PR to branch `develop`
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have run or added tests to cover my contribution
1 change: 1 addition & 0 deletions core/cat/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def get_supported_env_variables():
"CCAT_CORS_ENABLED": "true",
"CCAT_CACHE_TYPE": "in_memory",
"CCAT_CACHE_DIR": "/tmp",
"CCAT_QDRANT_CLIENT_TIMEOUT": None,
}


Expand Down
4 changes: 2 additions & 2 deletions core/cat/looking_glass/cheshire_cat.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from langchain_core.runnables import RunnableLambda
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers.string import StrOutputParser
from langchain_community.llms import Cohere
from langchain_cohere import ChatCohere
from langchain_openai import ChatOpenAI, OpenAI
from langchain_google_genai import ChatGoogleGenerativeAI

Expand Down Expand Up @@ -208,7 +208,7 @@ def load_language_embedder(self) -> embedders.EmbedderSettings:
# For Azure avoid automatic embedder selection

# Cohere
elif type(self._llm) in [Cohere]:
elif type(self._llm) in [ChatCohere]:
embedder = embedders.EmbedderCohereConfig.get_embedder_from_config(
{
"cohere_api_key": self._llm.cohere_api_key,
Expand Down
2 changes: 1 addition & 1 deletion core/cat/looking_glass/stray_cat.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,7 @@ def classify(
Allowed classes are:
{labels_list}{examples_list}

"{sentence}" -> """
Just output the class, nothing else."""

response = self.llm(prompt)

Expand Down
4 changes: 4 additions & 0 deletions core/cat/memory/vector_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ def connect_to_vector_memory(self) -> None:
qdrant_https = is_https(qdrant_host)
qdrant_host = extract_domain_from_url(qdrant_host)
qdrant_api_key = get_env("CCAT_QDRANT_API_KEY")

qdrant_client_timeout = get_env("CCAT_QDRANT_CLIENT_TIMEOUT")
qdrant_client_timeout = int(qdrant_client_timeout) if qdrant_client_timeout is not None else None

try:
s = socket.socket()
Expand All @@ -81,6 +84,7 @@ def connect_to_vector_memory(self) -> None:
port=qdrant_port,
https=qdrant_https,
api_key=qdrant_api_key,
timeout=qdrant_client_timeout
)

def delete_collection(self, collection_name: str):
Expand Down
75 changes: 46 additions & 29 deletions core/cat/memory/vector_memory_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ def check_embedding_size(self):
== self.embedder_size
)
alias = self.embedder_name + "_" + self.collection_name
if (
alias
== self.client.get_collection_aliases(self.collection_name)
.aliases[0]
.alias_name

existing_aliases = self.client.get_collection_aliases(self.collection_name).aliases

if ( len(existing_aliases) > 0 and
alias == existing_aliases[0].alias_name
and same_size
):
log.debug(f'Collection "{self.collection_name}" has the same embedder')
Expand Down Expand Up @@ -94,31 +94,48 @@ def create_db_collection_if_not_exists(self):

# create collection
def create_collection(self):
log.warning(f'Creating collection "{self.collection_name}" ...')
self.client.create_collection(
collection_name=self.collection_name,
vectors_config=VectorParams(
size=self.embedder_size, distance=Distance.COSINE
),
# hybrid mode: original vector on Disk, quantized vector in RAM
optimizers_config=OptimizersConfigDiff(memmap_threshold=20000),
quantization_config=ScalarQuantization(
scalar=ScalarQuantizationConfig(
type=ScalarType.INT8, quantile=0.95, always_ram=True
)
),
)

self.client.update_collection_aliases(
change_aliases_operations=[
CreateAliasOperation(
create_alias=CreateAlias(
collection_name=self.collection_name,
alias_name=self.embedder_name + "_" + self.collection_name,
try:
log.warning(f'Creating collection "{self.collection_name}" ...')
self.client.create_collection(
collection_name=self.collection_name,
vectors_config=VectorParams(
size=self.embedder_size, distance=Distance.COSINE
),
# hybrid mode: original vector on Disk, quantized vector in RAM
optimizers_config=OptimizersConfigDiff(memmap_threshold=20000),
quantization_config=ScalarQuantization(
scalar=ScalarQuantizationConfig(
type=ScalarType.INT8, quantile=0.95, always_ram=True
)
)
]
)
),
)
except Exception as e:
log.error(f"Error creating collection {self.collection_name}. Try setting a higher timeout value in CCAT_QDRANT_CLIENT_TIMEOUT: {e}")
self.client.delete_collection(self.collection_name)
raise

try:
alias_name=self.embedder_name + "_" + self.collection_name
log.warning(f'Creating alias {alias_name} for collection "{self.collection_name}" ...')

self.client.update_collection_aliases(
change_aliases_operations=[
CreateAliasOperation(
create_alias=CreateAlias(
collection_name=self.collection_name,
alias_name=alias_name,
)
)
]
)

log.warning(f'Created alias {alias_name} for collection "{self.collection_name}" ...')
except Exception as e:
log.error(f"Error creating collection alias {alias_name} for collection {self.collection_name}: {e}")
self.client.delete_collection(self.collection_name)
log.error(f" collection {self.collection_name} deleted")
raise


# adapted from https://github.com/langchain-ai/langchain/blob/bfc12a4a7644cfc4d832cc4023086a7a5374f46a/libs/langchain/langchain/vectorstores/qdrant.py#L1965
def _qdrant_filter_from_dict(self, filter: dict) -> Filter:
Expand Down
30 changes: 21 additions & 9 deletions core/cat/routes/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,18 @@ async def get_available_plugins(
# index registry plugins by url
registry_plugins_index = {}
for p in registry_plugins:
plugin_url = p["url"]
plugin_url = p.get("plugin_url", None)
if plugin_url is None:
log.warning(f"Plugin {p.get('name')} has no `plugin_url`. It will be skipped from the registry list.")
continue
# url = p.get("url", None)
# if url and url != plugin_url:
# log.info(f"Plugin {p.get('name')} has `url` {url} different from `plugin_url` {plugin_url}. please check the plugin.")
if plugin_url in registry_plugins_index:
current = registry_plugins_index[plugin_url]
log.warning(f"duplicate plugin_url {plugin_url} found in registry. Plugins {p.get('name')} has same url than {current.get('name')}. Skipping.")
continue

registry_plugins_index[plugin_url] = p

# get active plugins
Expand All @@ -53,19 +64,20 @@ async def get_available_plugins(
manifest["endpoints"] = [{"name": endpoint.name, "tags": endpoint.tags} for endpoint in p.endpoints]
manifest["forms"] = [{"name": form.name} for form in p.forms]

# do not show already installed plugins among registry plugins
r = registry_plugins_index.pop(manifest["plugin_url"], None)

# filter by query
plugin_text = [str(field) for field in manifest.values()]
plugin_text = " ".join(plugin_text).lower()

if (query is None) or (query.lower() in plugin_text):
for r in registry_plugins:
if r["plugin_url"] == p.manifest["plugin_url"]:
if r["version"] != p.manifest["version"]:
manifest["upgrade"] = r["version"]
if r is not None:
r_version = r.get("version", None)
if r_version is not None and r_version != p.manifest.get("version"):
manifest["upgrade"] = r["version"]
installed_plugins.append(manifest)

# do not show already installed plugins among registry plugins
registry_plugins_index.pop(manifest["plugin_url"], None)

return {
"filters": {
"query": query,
Expand Down Expand Up @@ -298,4 +310,4 @@ async def delete_plugin(
# remove folder, hooks and tools
ccat.mad_hatter.uninstall_plugin(plugin_id)

return {"deleted": plugin_id}
return {"deleted": plugin_id}
6 changes: 3 additions & 3 deletions core/cat/routes/websocket/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ async def websocket_endpoint(
except WebSocketDisconnect:
log.info(f"WebSocket connection closed for user {cat.user_id}")
finally:

# cat's working memory in this scope has not been updated
#cat.load_working_memory_from_cache()
cat.load_working_memory_from_cache()

# Remove connection on disconnect
websocket_manager.remove_connection(cat.user_id)
27 changes: 10 additions & 17 deletions core/cat/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import Dict, Tuple
from pydantic import BaseModel, ConfigDict

from langchain.evaluation import StringDistance, load_evaluator, EvaluatorType
from rapidfuzz.distance import Levenshtein
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_core.utils import get_colored_text
Expand Down Expand Up @@ -145,7 +145,7 @@ def explicit_error_message(e):
def deprecation_warning(message: str, skip=3):
"""Log a deprecation warning with caller's information.
"skip" is the number of stack levels to go back to the caller info."""

caller = get_caller_info(skip, return_short=False)

# Format and log the warning message
Expand All @@ -155,24 +155,17 @@ def deprecation_warning(message: str, skip=3):


def levenshtein_distance(prediction: str, reference: str) -> int:
jaro_evaluator = load_evaluator(
EvaluatorType.STRING_DISTANCE, distance=StringDistance.LEVENSHTEIN
)
result = jaro_evaluator.evaluate_strings(
prediction=prediction,
reference=reference,
)
return result["score"]

res = Levenshtein.normalized_distance(prediction, reference)
return res

def parse_json(json_string: str, pydantic_model: BaseModel = None) -> dict:
# instantiate parser
parser = JsonOutputParser(pydantic_object=pydantic_model)

# clean to help small LLMs
replaces = {
"\_": "_",
"\-": "-",
"\\_": "_",
"\\-": "-",
"None": "null",
"{{": "{",
"}}": "}",
Expand All @@ -185,7 +178,7 @@ def parse_json(json_string: str, pydantic_model: BaseModel = None) -> dict:

# parse
parsed = parser.parse(json_string[start_index:])

if pydantic_model:
return pydantic_model(**parsed)
return parsed
Expand Down Expand Up @@ -213,7 +206,7 @@ def match_prompt_variables(
prompt_template = \
prompt_template.replace("{" + m + "}", "")
log.debug(f"Placeholder '{m}' not found in prompt variables, removed")

return prompt_variables, prompt_template


Expand Down Expand Up @@ -255,7 +248,7 @@ def get_caller_info(skip=2, return_short=True, return_string=True):
start = 0 + skip
if len(stack) < start + 1:
return None

parentframe = stack[start][0]

# module and packagename.
Expand Down Expand Up @@ -347,7 +340,7 @@ class BaseModelDict(BaseModel):
def __getitem__(self, key):
# deprecate dictionary usage
deprecation_warning(
f'To get `{key}` use dot notation instead of dictionary keys, example:'
f'To get `{key}` use dot notation instead of dictionary keys, example:'
f'`obj.{key}` instead of `obj["{key}"]`'
)

Expand Down
2 changes: 1 addition & 1 deletion core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "Cheshire-Cat"
description = "Production ready AI assistant framework"
version = "1.9.1"
version = "1.9.2"
requires-python = ">=3.10"
license = { file = "LICENSE" }
authors = [
Expand Down
33 changes: 19 additions & 14 deletions core/tests/cache/test_core_caches.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,23 @@ def create_cache(cache_type):
assert False



@pytest.mark.parametrize("cache_type", ["in_memory", "file_system"])
def test_cache_creation(cache_type):

cache = create_cache(cache_type)

if cache_type == "in_memory":
assert cache.items == {}
assert cache.max_items == 100
else:
assert cache.cache_dir == "/tmp_cache"
assert os.path.exists("/tmp_cache")
assert os.listdir("/tmp_cache") == []
try:
cache = create_cache(cache_type)

if cache_type == "in_memory":
assert cache.items == {}
assert cache.max_items == 100
else:
assert cache.cache_dir == "/tmp_cache"
assert os.path.exists("/tmp_cache")
assert os.listdir("/tmp_cache") == []
finally:
import shutil
if os.path.exists("/tmp_cache"):
shutil.rmtree("/tmp_cache")


@pytest.mark.parametrize("cache_type", ["in_memory", "file_system"])
Expand All @@ -36,13 +41,13 @@ def test_cache_get_insert(cache_type):
cache = create_cache(cache_type)

assert cache.get_item("a") is None
c1 = CacheItem("a", [])

c1 = CacheItem("a", [])
cache.insert(c1)
assert cache.get_item("a").value == []
assert cache.get_value("a") == []


c1.value = [0]
cache.insert(c1) # will be overwritten
assert cache.get_item("a").value == [0]
Expand All @@ -64,7 +69,7 @@ def test_cache_delete(cache_type):

c1 = CacheItem("a", [])
cache.insert(c1)

cache.delete("a")
assert cache.get_item("a") is None

Expand Down
Loading