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.
@@ -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)