citadeldb-langchain 2.0.0__tar.gz
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.
- citadeldb_langchain-2.0.0/.gitignore +18 -0
- citadeldb_langchain-2.0.0/PKG-INFO +112 -0
- citadeldb_langchain-2.0.0/README.md +89 -0
- citadeldb_langchain-2.0.0/pyproject.toml +41 -0
- citadeldb_langchain-2.0.0/src/citadeldb_langchain/__init__.py +14 -0
- citadeldb_langchain-2.0.0/src/citadeldb_langchain/chat_history.py +136 -0
- citadeldb_langchain-2.0.0/src/citadeldb_langchain/vector_store.py +375 -0
- citadeldb_langchain-2.0.0/tests/test_chat_history.py +148 -0
- citadeldb_langchain-2.0.0/tests/test_conformance.py +30 -0
- citadeldb_langchain-2.0.0/tests/test_vector_store.py +429 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/target
|
|
2
|
+
**/*.rs.bk
|
|
3
|
+
*.swp
|
|
4
|
+
*.swo
|
|
5
|
+
*~
|
|
6
|
+
.DS_Store
|
|
7
|
+
site/public/
|
|
8
|
+
site/static/wasm/*.wasm
|
|
9
|
+
site/static/wasm/*.js
|
|
10
|
+
/notes/
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.py[cod]
|
|
13
|
+
# maturin build output; the .pyd is caught by the line above only by accident.
|
|
14
|
+
*.pdb
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
/dist/
|
|
18
|
+
packaging/*/dist/
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: citadeldb-langchain
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: LangChain vector store and chat history backed by Citadel: encrypted at rest, with deletes that destroy the key
|
|
5
|
+
Project-URL: Homepage, https://citadeldb.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/yp3y5akh0v/citadel
|
|
7
|
+
Author: Yuriy Peysakhov
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: chat-history,encryption,langchain,memory,rag,vector-store
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Database
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: citadeldb<3,>=2.0
|
|
17
|
+
Requires-Dist: langchain-core<2,>=0.3
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: langchain-tests>=0.3; extra == 'test'
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# citadeldb-langchain
|
|
25
|
+
|
|
26
|
+
A [LangChain](https://github.com/langchain-ai/langchain) `VectorStore` and
|
|
27
|
+
`BaseChatMessageHistory` backed by [Citadel](https://citadeldb.dev). Encrypted at rest,
|
|
28
|
+
embedded in your process, and deletes that destroy the key, not just the row.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install citadeldb-langchain
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Vector store
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from langchain_openai import OpenAIEmbeddings
|
|
38
|
+
from citadeldb_langchain import CitadelVectorStore
|
|
39
|
+
|
|
40
|
+
store = CitadelVectorStore(OpenAIEmbeddings(), "corpus.cdl", key="your-passphrase")
|
|
41
|
+
|
|
42
|
+
store.add_texts(["the deploy failed because the disk was full"], ids=["note-1"])
|
|
43
|
+
store.similarity_search("why did the release break?", k=1)
|
|
44
|
+
|
|
45
|
+
retriever = store.as_retriever(search_kwargs={"k": 4})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The width is read from your embedding model on construction, so nothing has to be
|
|
49
|
+
configured to match it. Pass `dim=` to skip that probe.
|
|
50
|
+
|
|
51
|
+
Adding an id that is already stored replaces it, so re-indexing a document does not
|
|
52
|
+
duplicate it.
|
|
53
|
+
|
|
54
|
+
### Deletes destroy the key
|
|
55
|
+
|
|
56
|
+
Every document is sealed under its own key. Deleting destroys that key and then removes the
|
|
57
|
+
row, so any ciphertext surviving elsewhere stays unreadable.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
store.delete(["note-1"]) # named ids
|
|
61
|
+
store.clear() # the whole corpus, deliberately
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`delete()` with no ids is a no-op, matching `InMemoryVectorStore`. Emptying the store is
|
|
65
|
+
`clear()`, because erasure cannot be undone.
|
|
66
|
+
|
|
67
|
+
### Filters
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
store.similarity_search("...", k=4, filter={"source": "handbook.pdf"})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The filter is evaluated inside the scan, so it narrows candidates before top-k rather than
|
|
74
|
+
trimming results after it, and `k` is `k`: a filter matching only distant documents still
|
|
75
|
+
returns them, however many others outrank them.
|
|
76
|
+
|
|
77
|
+
## Chat history
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from citadeldb_langchain import CitadelChatMessageHistory
|
|
81
|
+
|
|
82
|
+
history = CitadelChatMessageHistory("user-123", "chats.cdl", key="your-passphrase")
|
|
83
|
+
history.add_user_message("remember my dog is called Mochi")
|
|
84
|
+
history.messages
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Messages round-trip through LangChain's own serialization, so tool calls, block content
|
|
88
|
+
and `additional_kwargs` all survive. `clear()` destroys each message's key, so a cleared
|
|
89
|
+
conversation is unreadable.
|
|
90
|
+
|
|
91
|
+
Use it with `RunnableWithMessageHistory` the same way as any other history:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from langchain_core.runnables.history import RunnableWithMessageHistory
|
|
95
|
+
|
|
96
|
+
chain = RunnableWithMessageHistory(
|
|
97
|
+
runnable, # your chain
|
|
98
|
+
lambda session_id: CitadelChatMessageHistory(session_id, "chats.cdl", key="..."),
|
|
99
|
+
input_messages_key="input",
|
|
100
|
+
history_messages_key="history",
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Notes
|
|
105
|
+
|
|
106
|
+
Citadel is embedded and one process owns the file. A path already open on this thread,
|
|
107
|
+
under the same passphrase, is shared, so the vector store and the chat history can sit on
|
|
108
|
+
one encrypted database; construct them on the same thread.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
Apache-2.0
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# citadeldb-langchain
|
|
2
|
+
|
|
3
|
+
A [LangChain](https://github.com/langchain-ai/langchain) `VectorStore` and
|
|
4
|
+
`BaseChatMessageHistory` backed by [Citadel](https://citadeldb.dev). Encrypted at rest,
|
|
5
|
+
embedded in your process, and deletes that destroy the key, not just the row.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install citadeldb-langchain
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Vector store
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from langchain_openai import OpenAIEmbeddings
|
|
15
|
+
from citadeldb_langchain import CitadelVectorStore
|
|
16
|
+
|
|
17
|
+
store = CitadelVectorStore(OpenAIEmbeddings(), "corpus.cdl", key="your-passphrase")
|
|
18
|
+
|
|
19
|
+
store.add_texts(["the deploy failed because the disk was full"], ids=["note-1"])
|
|
20
|
+
store.similarity_search("why did the release break?", k=1)
|
|
21
|
+
|
|
22
|
+
retriever = store.as_retriever(search_kwargs={"k": 4})
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The width is read from your embedding model on construction, so nothing has to be
|
|
26
|
+
configured to match it. Pass `dim=` to skip that probe.
|
|
27
|
+
|
|
28
|
+
Adding an id that is already stored replaces it, so re-indexing a document does not
|
|
29
|
+
duplicate it.
|
|
30
|
+
|
|
31
|
+
### Deletes destroy the key
|
|
32
|
+
|
|
33
|
+
Every document is sealed under its own key. Deleting destroys that key and then removes the
|
|
34
|
+
row, so any ciphertext surviving elsewhere stays unreadable.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
store.delete(["note-1"]) # named ids
|
|
38
|
+
store.clear() # the whole corpus, deliberately
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`delete()` with no ids is a no-op, matching `InMemoryVectorStore`. Emptying the store is
|
|
42
|
+
`clear()`, because erasure cannot be undone.
|
|
43
|
+
|
|
44
|
+
### Filters
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
store.similarity_search("...", k=4, filter={"source": "handbook.pdf"})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The filter is evaluated inside the scan, so it narrows candidates before top-k rather than
|
|
51
|
+
trimming results after it, and `k` is `k`: a filter matching only distant documents still
|
|
52
|
+
returns them, however many others outrank them.
|
|
53
|
+
|
|
54
|
+
## Chat history
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from citadeldb_langchain import CitadelChatMessageHistory
|
|
58
|
+
|
|
59
|
+
history = CitadelChatMessageHistory("user-123", "chats.cdl", key="your-passphrase")
|
|
60
|
+
history.add_user_message("remember my dog is called Mochi")
|
|
61
|
+
history.messages
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Messages round-trip through LangChain's own serialization, so tool calls, block content
|
|
65
|
+
and `additional_kwargs` all survive. `clear()` destroys each message's key, so a cleared
|
|
66
|
+
conversation is unreadable.
|
|
67
|
+
|
|
68
|
+
Use it with `RunnableWithMessageHistory` the same way as any other history:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from langchain_core.runnables.history import RunnableWithMessageHistory
|
|
72
|
+
|
|
73
|
+
chain = RunnableWithMessageHistory(
|
|
74
|
+
runnable, # your chain
|
|
75
|
+
lambda session_id: CitadelChatMessageHistory(session_id, "chats.cdl", key="..."),
|
|
76
|
+
input_messages_key="input",
|
|
77
|
+
history_messages_key="history",
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Notes
|
|
82
|
+
|
|
83
|
+
Citadel is embedded and one process owns the file. A path already open on this thread,
|
|
84
|
+
under the same passphrase, is shared, so the vector store and the chat history can sit on
|
|
85
|
+
one encrypted database; construct them on the same thread.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
Apache-2.0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "citadeldb-langchain"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "LangChain vector store and chat history backed by Citadel: encrypted at rest, with deletes that destroy the key"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "Yuriy Peysakhov" }]
|
|
13
|
+
keywords = ["langchain", "rag", "vector-store", "chat-history", "memory", "encryption"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Database",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
]
|
|
21
|
+
# The precomputed vector needs the `embedding` field added in citadeldb 2.0.
|
|
22
|
+
dependencies = ["citadeldb>=2.0,<3", "langchain-core>=0.3,<2"]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
# langchain-tests carries the suite that test_conformance.py runs.
|
|
26
|
+
test = ["pytest>=8", "pytest-asyncio>=0.23", "langchain-tests>=0.3"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://citadeldb.dev"
|
|
30
|
+
Repository = "https://github.com/yp3y5akh0v/citadel"
|
|
31
|
+
|
|
32
|
+
# The version comes from the release tag, so there is nothing to bump.
|
|
33
|
+
[tool.hatch.version]
|
|
34
|
+
source = "vcs"
|
|
35
|
+
raw-options = { root = "../..", tag_regex = '^v(?P<version>\d+\.\d+\.\d+)$' }
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/citadeldb_langchain"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""LangChain storage backed by Citadel, encrypted at rest."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .chat_history import CitadelChatMessageHistory
|
|
6
|
+
from .vector_store import CitadelVectorStore
|
|
7
|
+
|
|
8
|
+
__all__ = ["CitadelVectorStore", "CitadelChatMessageHistory", "__version__"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("citadeldb-langchain")
|
|
13
|
+
except PackageNotFoundError: # running from a source tree, never installed
|
|
14
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""LangChain chat message history over an encrypted Citadel region."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import Any, Sequence
|
|
6
|
+
|
|
7
|
+
import citadeldb
|
|
8
|
+
from langchain_core.chat_history import BaseChatMessageHistory
|
|
9
|
+
from langchain_core.messages import BaseMessage, message_to_dict, messages_from_dict
|
|
10
|
+
|
|
11
|
+
KIND = "message"
|
|
12
|
+
DEFAULT_PATH = "langchain_history.cdl"
|
|
13
|
+
DEFAULT_REGION = "chat_history"
|
|
14
|
+
PAGE = 10_000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _page(mem: Any, region: str, session_id: str) -> list[Any]:
|
|
18
|
+
"""Page to the end: one fetch is bounded, and a partial clear must not look whole."""
|
|
19
|
+
out: list[Any] = []
|
|
20
|
+
after = None
|
|
21
|
+
while True:
|
|
22
|
+
got = mem.fetch(region, KIND, payload_filter={"sid": session_id}, limit=PAGE,
|
|
23
|
+
after_id=after)
|
|
24
|
+
out.extend(got)
|
|
25
|
+
if len(got) < PAGE:
|
|
26
|
+
return out
|
|
27
|
+
after = got[-1].id
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _messages(mem: Any, region: str, session_id: str) -> list[BaseMessage]:
|
|
31
|
+
hits = _page(mem, region, session_id)
|
|
32
|
+
return messages_from_dict([h.payload["msg"] for h in hits])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _add(
|
|
36
|
+
mem: Any, region: str, session_id: str, messages: Sequence[BaseMessage]
|
|
37
|
+
) -> None:
|
|
38
|
+
atoms = [
|
|
39
|
+
{
|
|
40
|
+
"kind": KIND,
|
|
41
|
+
# The message content is what recall would match on.
|
|
42
|
+
"text": _searchable(m),
|
|
43
|
+
"payload": {"sid": session_id, "msg": message_to_dict(m)},
|
|
44
|
+
}
|
|
45
|
+
for m in messages
|
|
46
|
+
]
|
|
47
|
+
if atoms:
|
|
48
|
+
# One batch draws one id range, so list order survives as id order.
|
|
49
|
+
mem.remember_batch(region, atoms)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _clear(mem: Any, region: str, session_id: str) -> int:
|
|
53
|
+
hits = _page(mem, region, session_id)
|
|
54
|
+
if not hits:
|
|
55
|
+
return 0
|
|
56
|
+
return mem.forget(region, [h.id for h in hits]).erased_count
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _searchable(message: BaseMessage) -> str:
|
|
60
|
+
"""Message text, or a rendering of it when the content is a block list."""
|
|
61
|
+
content = message.content
|
|
62
|
+
if isinstance(content, str) and content:
|
|
63
|
+
return content
|
|
64
|
+
if isinstance(content, list):
|
|
65
|
+
parts = [
|
|
66
|
+
b["text"]
|
|
67
|
+
for b in content
|
|
68
|
+
if isinstance(b, dict) and isinstance(b.get("text"), str) and b["text"]
|
|
69
|
+
]
|
|
70
|
+
if parts:
|
|
71
|
+
return " ".join(parts)
|
|
72
|
+
return message.type
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CitadelChatMessageHistory(BaseChatMessageHistory):
|
|
76
|
+
"""A LangChain chat history backed by one encrypted Citadel region."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
session_id: str,
|
|
81
|
+
path: str = DEFAULT_PATH,
|
|
82
|
+
key: str = "",
|
|
83
|
+
*,
|
|
84
|
+
region: str = DEFAULT_REGION,
|
|
85
|
+
embedder: Any | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
if not key:
|
|
88
|
+
raise ValueError("a passphrase is required: transcripts are the payload")
|
|
89
|
+
self.session_id = session_id
|
|
90
|
+
try:
|
|
91
|
+
self._db = citadeldb.connect(path, key=key, region_keys=True)
|
|
92
|
+
except citadeldb.OperationalError as e:
|
|
93
|
+
if "locked" not in str(e):
|
|
94
|
+
raise
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"{path} is open in another process. Citadel is embedded, so one "
|
|
97
|
+
f"process owns the file."
|
|
98
|
+
) from e
|
|
99
|
+
self._mem = self._db.memory()
|
|
100
|
+
self._region = region
|
|
101
|
+
# Idempotent for a region of the same width, so a dim clash raises here.
|
|
102
|
+
self._mem.create_encrypted_region(
|
|
103
|
+
region, embedder or citadeldb.MockEmbedder(dim=64)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def messages(self) -> list[BaseMessage]:
|
|
108
|
+
return _messages(self._mem, self._region, self.session_id)
|
|
109
|
+
|
|
110
|
+
def add_messages(self, messages: Sequence[BaseMessage]) -> None:
|
|
111
|
+
_add(self._mem, self._region, self.session_id, messages)
|
|
112
|
+
|
|
113
|
+
def clear(self) -> None:
|
|
114
|
+
_clear(self._mem, self._region, self.session_id)
|
|
115
|
+
|
|
116
|
+
# ---- async ------------------------------------------------------------
|
|
117
|
+
# The bindings are sync, so a worker thread keeps the event loop free.
|
|
118
|
+
|
|
119
|
+
async def aget_messages(self) -> list[BaseMessage]:
|
|
120
|
+
return await asyncio.to_thread(
|
|
121
|
+
_messages, self._mem, self._region, self.session_id
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None:
|
|
125
|
+
await asyncio.to_thread(
|
|
126
|
+
_add, self._mem, self._region, self.session_id, list(messages)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def aclear(self) -> None:
|
|
130
|
+
await asyncio.to_thread(_clear, self._mem, self._region, self.session_id)
|
|
131
|
+
|
|
132
|
+
# ---- beyond the interface ---------------------------------------------
|
|
133
|
+
|
|
134
|
+
def forget(self) -> int:
|
|
135
|
+
"""Destroy this session's messages, returning the number erased."""
|
|
136
|
+
return _clear(self._mem, self._region, self.session_id)
|