attestari-langchain 0.0.1__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.
- attestari_langchain-0.0.1/.gitignore +43 -0
- attestari_langchain-0.0.1/PKG-INFO +111 -0
- attestari_langchain-0.0.1/README.md +98 -0
- attestari_langchain-0.0.1/example.py +56 -0
- attestari_langchain-0.0.1/pyproject.toml +22 -0
- attestari_langchain-0.0.1/src/attestari_langchain/__init__.py +125 -0
- attestari_langchain-0.0.1/tests/test_attestari_memory.py +104 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.env
|
|
11
|
+
.python-version
|
|
12
|
+
|
|
13
|
+
# Tooling
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.coverage
|
|
18
|
+
htmlcov/
|
|
19
|
+
|
|
20
|
+
# Data / local
|
|
21
|
+
*.sqlite
|
|
22
|
+
*.db
|
|
23
|
+
eval/data/
|
|
24
|
+
.attestari/
|
|
25
|
+
|
|
26
|
+
# Internal planning / strategy docs — never publish
|
|
27
|
+
internal/
|
|
28
|
+
|
|
29
|
+
# Node / TS SDK
|
|
30
|
+
node_modules/
|
|
31
|
+
clients/ts/dist/
|
|
32
|
+
*.tsbuildinfo
|
|
33
|
+
|
|
34
|
+
# OS / editor
|
|
35
|
+
.DS_Store
|
|
36
|
+
.idea/
|
|
37
|
+
.vscode/
|
|
38
|
+
|
|
39
|
+
# Local agent tooling (launch.json can hold a dev KEK / local env)
|
|
40
|
+
.claude/
|
|
41
|
+
|
|
42
|
+
# Screen-recording source for docs/deletion-demo.gif — the GIF is committed, the source is not
|
|
43
|
+
deletion-demo.mp4
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: attestari-langchain
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: LangChain integration for Attestari — the auditable memory layer for AI agents.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: agents,ai,attestari,langchain,memory
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: attestari>=0.0.1
|
|
9
|
+
Requires-Dist: langchain-core>=0.3
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# attestari-langchain
|
|
15
|
+
|
|
16
|
+
LangChain integration for [Attestari](https://github.com/attestari/attestari) — the
|
|
17
|
+
auditable memory layer for AI agents.
|
|
18
|
+
|
|
19
|
+
Two pieces, both built on stable `langchain-core` primitives, so instead of
|
|
20
|
+
replaying a raw chat transcript your chain stores **facts with provenance** —
|
|
21
|
+
bi-temporal recall, a tamper-evident audit trail, and a signed deletion
|
|
22
|
+
certificate, on plain Postgres or zero-dependency in memory:
|
|
23
|
+
|
|
24
|
+
- **`AttestariRetriever`** (`BaseRetriever`) — recall a subject's relevant facts as
|
|
25
|
+
`Document`s, each carrying provenance in its metadata (fact id, source episode,
|
|
26
|
+
valid-from, confidence). Supports bi-temporal `as_of` recall. The idiomatic way
|
|
27
|
+
to inject long-term memory into an LCEL / RAG chain or an agent tool.
|
|
28
|
+
- **`AttestariChatMessageHistory`** (`BaseChatMessageHistory`) — a drop-in history for
|
|
29
|
+
`RunnableWithMessageHistory`: it *learns facts* from each **human** turn (not a raw
|
|
30
|
+
transcript, and never from the assistant's own words), surfaces the subject's known facts as a system message, and maps
|
|
31
|
+
`clear()` to Attestari's provable deletion (`forget`).
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install attestari-langchain # pulls in attestari + langchain-core
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Use — inject memory into a chain (AttestariRetriever)
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from attestari import Memory
|
|
43
|
+
from attestari_langchain import AttestariRetriever
|
|
44
|
+
|
|
45
|
+
mem = Memory() # or Memory.local() / Memory.postgres()
|
|
46
|
+
mem.add("Hi, I'm Alice. I live in Toronto.", subject_id="user_42")
|
|
47
|
+
|
|
48
|
+
retriever = AttestariRetriever(
|
|
49
|
+
mem=mem, # the Attestari engine
|
|
50
|
+
subject_id="user_42", # an opaque pseudonym, not raw PII
|
|
51
|
+
k=5, # facts to recall
|
|
52
|
+
# as_of="2022-01-01", # optional: recall what was true then
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
docs = retriever.invoke("where does the user live?")
|
|
56
|
+
# -> [Document("user_42 lives in Toronto", metadata={fact_id, source_episode_id, score, ...})]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> The zero-dependency default extractor understands **first-person** statements
|
|
60
|
+
> ("I live in…", "my name is…"). For arbitrary third-person text, install
|
|
61
|
+
> `attestari[anthropic]` and set `ANTHROPIC_API_KEY` for Claude-powered extraction.
|
|
62
|
+
|
|
63
|
+
Drop `retriever` into any LCEL chain the way you would any other retriever; each
|
|
64
|
+
returned `Document` carries provenance in `.metadata` you can show or audit.
|
|
65
|
+
|
|
66
|
+
## Use — conversational memory (AttestariChatMessageHistory)
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from attestari import Memory
|
|
70
|
+
from attestari_langchain import AttestariChatMessageHistory
|
|
71
|
+
from langchain_core.messages import HumanMessage
|
|
72
|
+
|
|
73
|
+
history = AttestariChatMessageHistory(mem=Memory(), subject_id="user_42")
|
|
74
|
+
history.add_messages([HumanMessage("Hi, I'm Alice and I live in Toronto.")])
|
|
75
|
+
history.messages # -> [SystemMessage("Known facts about the user:\n- user lives_in Toronto")]
|
|
76
|
+
history.clear() # provable deletion (forget) for this subject
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Wire it into `RunnableWithMessageHistory` as the `get_session_history` factory:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from langchain_core.runnables.history import RunnableWithMessageHistory
|
|
83
|
+
|
|
84
|
+
chain_with_memory = RunnableWithMessageHistory(
|
|
85
|
+
chain,
|
|
86
|
+
lambda session_id: AttestariChatMessageHistory(mem=Memory.local(), subject_id=session_id),
|
|
87
|
+
input_messages_key="input",
|
|
88
|
+
history_messages_key="history",
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Constructor reference
|
|
93
|
+
|
|
94
|
+
**`AttestariRetriever`**
|
|
95
|
+
|
|
96
|
+
| Field | Default | Purpose |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `mem` | — | The Attestari engine: `Memory()`, `Memory.local()`, or `Memory.postgres()`. |
|
|
99
|
+
| `subject_id` | `None` | Scope recall to one subject (opaque pseudonym). |
|
|
100
|
+
| `k` | `5` | How many facts to recall. |
|
|
101
|
+
| `as_of` | `None` | Bi-temporal instant — recall what was true `as_of` this ISO date. |
|
|
102
|
+
|
|
103
|
+
**`AttestariChatMessageHistory(mem, subject_id)`** — `add_messages(msgs)` ingests each
|
|
104
|
+
human turn (assistant messages are conversation, not testimony — they are not
|
|
105
|
+
attributed to the user as facts), `messages` returns the subject's live facts as one system message, and
|
|
106
|
+
`clear()` maps to provable deletion.
|
|
107
|
+
|
|
108
|
+
A runnable, no-LLM example is in [`example.py`](example.py):
|
|
109
|
+
`python clients/langchain/example.py`.
|
|
110
|
+
|
|
111
|
+
Apache-2.0.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# attestari-langchain
|
|
2
|
+
|
|
3
|
+
LangChain integration for [Attestari](https://github.com/attestari/attestari) — the
|
|
4
|
+
auditable memory layer for AI agents.
|
|
5
|
+
|
|
6
|
+
Two pieces, both built on stable `langchain-core` primitives, so instead of
|
|
7
|
+
replaying a raw chat transcript your chain stores **facts with provenance** —
|
|
8
|
+
bi-temporal recall, a tamper-evident audit trail, and a signed deletion
|
|
9
|
+
certificate, on plain Postgres or zero-dependency in memory:
|
|
10
|
+
|
|
11
|
+
- **`AttestariRetriever`** (`BaseRetriever`) — recall a subject's relevant facts as
|
|
12
|
+
`Document`s, each carrying provenance in its metadata (fact id, source episode,
|
|
13
|
+
valid-from, confidence). Supports bi-temporal `as_of` recall. The idiomatic way
|
|
14
|
+
to inject long-term memory into an LCEL / RAG chain or an agent tool.
|
|
15
|
+
- **`AttestariChatMessageHistory`** (`BaseChatMessageHistory`) — a drop-in history for
|
|
16
|
+
`RunnableWithMessageHistory`: it *learns facts* from each **human** turn (not a raw
|
|
17
|
+
transcript, and never from the assistant's own words), surfaces the subject's known facts as a system message, and maps
|
|
18
|
+
`clear()` to Attestari's provable deletion (`forget`).
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install attestari-langchain # pulls in attestari + langchain-core
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Use — inject memory into a chain (AttestariRetriever)
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from attestari import Memory
|
|
30
|
+
from attestari_langchain import AttestariRetriever
|
|
31
|
+
|
|
32
|
+
mem = Memory() # or Memory.local() / Memory.postgres()
|
|
33
|
+
mem.add("Hi, I'm Alice. I live in Toronto.", subject_id="user_42")
|
|
34
|
+
|
|
35
|
+
retriever = AttestariRetriever(
|
|
36
|
+
mem=mem, # the Attestari engine
|
|
37
|
+
subject_id="user_42", # an opaque pseudonym, not raw PII
|
|
38
|
+
k=5, # facts to recall
|
|
39
|
+
# as_of="2022-01-01", # optional: recall what was true then
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
docs = retriever.invoke("where does the user live?")
|
|
43
|
+
# -> [Document("user_42 lives in Toronto", metadata={fact_id, source_episode_id, score, ...})]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> The zero-dependency default extractor understands **first-person** statements
|
|
47
|
+
> ("I live in…", "my name is…"). For arbitrary third-person text, install
|
|
48
|
+
> `attestari[anthropic]` and set `ANTHROPIC_API_KEY` for Claude-powered extraction.
|
|
49
|
+
|
|
50
|
+
Drop `retriever` into any LCEL chain the way you would any other retriever; each
|
|
51
|
+
returned `Document` carries provenance in `.metadata` you can show or audit.
|
|
52
|
+
|
|
53
|
+
## Use — conversational memory (AttestariChatMessageHistory)
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from attestari import Memory
|
|
57
|
+
from attestari_langchain import AttestariChatMessageHistory
|
|
58
|
+
from langchain_core.messages import HumanMessage
|
|
59
|
+
|
|
60
|
+
history = AttestariChatMessageHistory(mem=Memory(), subject_id="user_42")
|
|
61
|
+
history.add_messages([HumanMessage("Hi, I'm Alice and I live in Toronto.")])
|
|
62
|
+
history.messages # -> [SystemMessage("Known facts about the user:\n- user lives_in Toronto")]
|
|
63
|
+
history.clear() # provable deletion (forget) for this subject
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Wire it into `RunnableWithMessageHistory` as the `get_session_history` factory:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from langchain_core.runnables.history import RunnableWithMessageHistory
|
|
70
|
+
|
|
71
|
+
chain_with_memory = RunnableWithMessageHistory(
|
|
72
|
+
chain,
|
|
73
|
+
lambda session_id: AttestariChatMessageHistory(mem=Memory.local(), subject_id=session_id),
|
|
74
|
+
input_messages_key="input",
|
|
75
|
+
history_messages_key="history",
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Constructor reference
|
|
80
|
+
|
|
81
|
+
**`AttestariRetriever`**
|
|
82
|
+
|
|
83
|
+
| Field | Default | Purpose |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `mem` | — | The Attestari engine: `Memory()`, `Memory.local()`, or `Memory.postgres()`. |
|
|
86
|
+
| `subject_id` | `None` | Scope recall to one subject (opaque pseudonym). |
|
|
87
|
+
| `k` | `5` | How many facts to recall. |
|
|
88
|
+
| `as_of` | `None` | Bi-temporal instant — recall what was true `as_of` this ISO date. |
|
|
89
|
+
|
|
90
|
+
**`AttestariChatMessageHistory(mem, subject_id)`** — `add_messages(msgs)` ingests each
|
|
91
|
+
human turn (assistant messages are conversation, not testimony — they are not
|
|
92
|
+
attributed to the user as facts), `messages` returns the subject's live facts as one system message, and
|
|
93
|
+
`clear()` maps to provable deletion.
|
|
94
|
+
|
|
95
|
+
A runnable, no-LLM example is in [`example.py`](example.py):
|
|
96
|
+
`python clients/langchain/example.py`.
|
|
97
|
+
|
|
98
|
+
Apache-2.0.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""AttestariRetriever + AttestariChatMessageHistory — no LLM or API key needed.
|
|
3
|
+
|
|
4
|
+
pip install langchain-core
|
|
5
|
+
python clients/langchain/example.py
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pathlib
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
# Make `attestari` and `attestari_langchain` importable without installing.
|
|
14
|
+
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
15
|
+
sys.path.insert(0, str(ROOT / "src"))
|
|
16
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent / "src"))
|
|
17
|
+
|
|
18
|
+
from attestari import Memory # noqa: E402
|
|
19
|
+
from attestari_langchain import AttestariChatMessageHistory, AttestariRetriever # noqa: E402
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> int:
|
|
23
|
+
mem = Memory()
|
|
24
|
+
history = AttestariChatMessageHistory(mem, subject_id="user_42")
|
|
25
|
+
|
|
26
|
+
# What RunnableWithMessageHistory would feed in as the conversation happens:
|
|
27
|
+
from langchain_core.messages import AIMessage, HumanMessage
|
|
28
|
+
|
|
29
|
+
history.add_messages([HumanMessage("Hi, I'm Alice and I live in Toronto.")])
|
|
30
|
+
history.add_messages([AIMessage("Nice to meet you, Alice!")])
|
|
31
|
+
history.add_messages([HumanMessage("I moved to Berlin.")])
|
|
32
|
+
|
|
33
|
+
print("Memory injected into the prompt (history.messages):")
|
|
34
|
+
print(" " + history.messages[0].content.replace("\n", "\n "))
|
|
35
|
+
|
|
36
|
+
# The retriever is how you inject relevant facts into a RAG/agent chain —
|
|
37
|
+
# each Document carries provenance you can show or audit.
|
|
38
|
+
retriever = AttestariRetriever(mem=mem, subject_id="user_42", k=3)
|
|
39
|
+
docs = retriever.invoke("where does the user live?")
|
|
40
|
+
print("\nAttestariRetriever results (with provenance):")
|
|
41
|
+
for d in docs:
|
|
42
|
+
print(f" - {d.page_content!r} [fact_id={d.metadata['fact_id'][:8]}…, "
|
|
43
|
+
f"score={d.metadata['score']:.2f}]")
|
|
44
|
+
|
|
45
|
+
# The differentiator: provable deletion via clear().
|
|
46
|
+
history.clear()
|
|
47
|
+
after = retriever.invoke("where does the user live?")
|
|
48
|
+
|
|
49
|
+
assert any("Berlin" in d.page_content for d in docs), "expected Berlin to supersede Toronto"
|
|
50
|
+
assert after == [], "expected nothing recalled after clear()"
|
|
51
|
+
print("\n✅ Recall, supersession (Berlin > Toronto), and provable deletion all hold.")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "attestari-langchain"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "LangChain integration for Attestari — the auditable memory layer for AI agents."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
keywords = ["ai", "agents", "memory", "attestari", "langchain"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"attestari>=0.0.1",
|
|
15
|
+
"langchain-core>=0.3",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest>=8"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/attestari_langchain"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""LangChain integration for Attestari — the auditable memory layer for AI agents.
|
|
2
|
+
|
|
3
|
+
Two pieces, both built on the stable `langchain-core` primitives:
|
|
4
|
+
|
|
5
|
+
- **`AttestariRetriever`** (`BaseRetriever`) — recall a subject's relevant facts and
|
|
6
|
+
hand them to a chain/agent as `Document`s, each carrying **provenance** in its
|
|
7
|
+
metadata (fact id, source episode, valid-from, confidence). Supports bi-temporal
|
|
8
|
+
`as_of` retrieval. This is the idiomatic way to inject long-term memory into a
|
|
9
|
+
modern LCEL / RAG chain or an agent tool.
|
|
10
|
+
- **`AttestariChatMessageHistory`** (`BaseChatMessageHistory`) — a drop-in history for
|
|
11
|
+
`RunnableWithMessageHistory`: it *learns facts* from each turn (not a raw
|
|
12
|
+
transcript), surfaces the subject's known facts as context, and maps `clear()`
|
|
13
|
+
to Attestari's provable deletion (`forget`).
|
|
14
|
+
|
|
15
|
+
from attestari import Memory
|
|
16
|
+
from attestari_langchain import AttestariRetriever
|
|
17
|
+
|
|
18
|
+
mem = Memory() # or Memory.postgres() for durable storage
|
|
19
|
+
mem.add("Alice lives in Toronto.", subject_id="user_42")
|
|
20
|
+
retriever = AttestariRetriever(mem=mem, subject_id="user_42")
|
|
21
|
+
retriever.invoke("where does the user live?") # -> [Document("user lives_in Toronto", ...)]
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from attestari import Memory
|
|
29
|
+
|
|
30
|
+
from langchain_core.callbacks import CallbackManagerForRetrieverRun
|
|
31
|
+
from langchain_core.chat_history import BaseChatMessageHistory
|
|
32
|
+
from langchain_core.documents import Document
|
|
33
|
+
from langchain_core.messages import BaseMessage, SystemMessage
|
|
34
|
+
from langchain_core.retrievers import BaseRetriever
|
|
35
|
+
from pydantic import ConfigDict
|
|
36
|
+
|
|
37
|
+
__all__ = ["AttestariRetriever", "AttestariChatMessageHistory"]
|
|
38
|
+
__version__ = "0.0.1"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _triple(edge: Any) -> str:
|
|
42
|
+
return f"{edge.subject} {edge.predicate.replace('_', ' ')} {edge.object}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AttestariRetriever(BaseRetriever):
|
|
46
|
+
"""Retrieve a subject's relevant facts as provenance-carrying `Document`s."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
49
|
+
|
|
50
|
+
mem: Memory
|
|
51
|
+
"""The Attestari engine — `Memory()` (in-memory) or `Memory.postgres()` (durable)."""
|
|
52
|
+
subject_id: str | None = None
|
|
53
|
+
"""Scope retrieval to one subject. Use an opaque pseudonym, not raw PII."""
|
|
54
|
+
k: int = 5
|
|
55
|
+
"""How many facts to recall."""
|
|
56
|
+
as_of: str | None = None
|
|
57
|
+
"""Optional bi-temporal instant — recall what was true `as_of` this date."""
|
|
58
|
+
|
|
59
|
+
def _get_relevant_documents(
|
|
60
|
+
self, query: str, *, run_manager: CallbackManagerForRetrieverRun
|
|
61
|
+
) -> list[Document]:
|
|
62
|
+
results = self.mem.search(
|
|
63
|
+
query, subject_id=self.subject_id, as_of=self.as_of, limit=self.k
|
|
64
|
+
)
|
|
65
|
+
docs: list[Document] = []
|
|
66
|
+
for r in results:
|
|
67
|
+
e = r.edge
|
|
68
|
+
docs.append(
|
|
69
|
+
Document(
|
|
70
|
+
page_content=_triple(e),
|
|
71
|
+
metadata={
|
|
72
|
+
"fact_id": e.fact_id,
|
|
73
|
+
"subject": e.subject,
|
|
74
|
+
"predicate": e.predicate,
|
|
75
|
+
"object": e.object,
|
|
76
|
+
"valid_from": e.valid_from.isoformat() if e.valid_from else None,
|
|
77
|
+
"source_episode_id": e.source_episode_id,
|
|
78
|
+
"confidence": e.confidence,
|
|
79
|
+
"score": r.score,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return docs
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AttestariChatMessageHistory(BaseChatMessageHistory):
|
|
87
|
+
"""A `BaseChatMessageHistory` that learns facts instead of storing a transcript.
|
|
88
|
+
|
|
89
|
+
Drop into `RunnableWithMessageHistory`. `add_messages` ingests each turn into
|
|
90
|
+
Attestari; `messages` returns the subject's current facts as a single system
|
|
91
|
+
message; `clear` performs provable deletion.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, mem: Memory, subject_id: str):
|
|
95
|
+
self.mem = mem
|
|
96
|
+
self.subject_id = subject_id
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def messages(self) -> list[BaseMessage]:
|
|
100
|
+
live = [
|
|
101
|
+
e
|
|
102
|
+
for e in self.mem.timeline(subject_id=self.subject_id)
|
|
103
|
+
if e.valid_to is None
|
|
104
|
+
]
|
|
105
|
+
if not live:
|
|
106
|
+
return []
|
|
107
|
+
facts = "\n".join(f"- {_triple(e)}" for e in live)
|
|
108
|
+
return [SystemMessage(content=f"Known facts about the user:\n{facts}")]
|
|
109
|
+
|
|
110
|
+
def add_messages(self, messages: list[BaseMessage]) -> None:
|
|
111
|
+
# Only HUMAN turns become memories. Extractors attribute first-person
|
|
112
|
+
# statements ("I", "my") to the subject — ingesting an AIMessage would
|
|
113
|
+
# mint facts about the *user* out of the assistant's own words (e.g.
|
|
114
|
+
# "I'd say Berlin is lovely" -> lives_in Berlin). The assistant's turns
|
|
115
|
+
# are conversation, not testimony.
|
|
116
|
+
for m in messages:
|
|
117
|
+
if m.type != "human":
|
|
118
|
+
continue
|
|
119
|
+
content = m.content if isinstance(m.content, str) else str(m.content)
|
|
120
|
+
if content.strip():
|
|
121
|
+
self.mem.add(content, subject_id=self.subject_id)
|
|
122
|
+
|
|
123
|
+
def clear(self) -> None:
|
|
124
|
+
"""Provable deletion: forget everything for this subject."""
|
|
125
|
+
self.mem.forget(self.subject_id)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests for the Attestari LangChain integration (retriever + chat history)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pathlib
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
|
11
|
+
sys.path.insert(0, str(ROOT / "src"))
|
|
12
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
|
|
13
|
+
|
|
14
|
+
pytest.importorskip("langchain_core", reason="needs langchain-core")
|
|
15
|
+
|
|
16
|
+
from langchain_core.messages import AIMessage, HumanMessage # noqa: E402
|
|
17
|
+
|
|
18
|
+
from attestari import Memory # noqa: E402
|
|
19
|
+
from attestari_langchain import AttestariChatMessageHistory, AttestariRetriever # noqa: E402
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# -- AttestariRetriever ----------------------------------------------------- #
|
|
23
|
+
|
|
24
|
+
def test_retriever_recalls_with_provenance():
|
|
25
|
+
mem = Memory()
|
|
26
|
+
mem.add("I live in Toronto and I work at Acme.", subject_id="alice")
|
|
27
|
+
docs = AttestariRetriever(mem=mem, subject_id="alice").invoke("where does the user live?")
|
|
28
|
+
assert any("Toronto" in d.page_content for d in docs)
|
|
29
|
+
d = docs[0]
|
|
30
|
+
assert d.metadata["fact_id"] and d.metadata["source_episode_id"]
|
|
31
|
+
assert "score" in d.metadata
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_retriever_supersession():
|
|
35
|
+
mem = Memory()
|
|
36
|
+
mem.add("I live in Toronto.", subject_id="alice")
|
|
37
|
+
mem.add("I moved to Berlin.", subject_id="alice")
|
|
38
|
+
docs = AttestariRetriever(mem=mem, subject_id="alice").invoke("where does the user live?")
|
|
39
|
+
blob = " ".join(d.page_content for d in docs)
|
|
40
|
+
assert "Berlin" in blob and "Toronto" not in blob
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_retriever_time_travel_as_of():
|
|
44
|
+
mem = Memory()
|
|
45
|
+
mem.add("I live in Toronto.", subject_id="alice", valid_from="2021-01-01")
|
|
46
|
+
mem.add("I moved to Berlin.", subject_id="alice", valid_from="2026-01-01")
|
|
47
|
+
docs = AttestariRetriever(mem=mem, subject_id="alice", as_of="2022-01-01").invoke(
|
|
48
|
+
"where does the user live?"
|
|
49
|
+
)
|
|
50
|
+
assert any("Toronto" in d.page_content for d in docs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_retriever_respects_k():
|
|
54
|
+
mem = Memory()
|
|
55
|
+
mem.add("I live in Toronto. I work at Acme. My name is Alice.", subject_id="alice")
|
|
56
|
+
docs = AttestariRetriever(mem=mem, subject_id="alice", k=1).invoke("tell me about the user")
|
|
57
|
+
assert len(docs) <= 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# -- AttestariChatMessageHistory -------------------------------------------- #
|
|
61
|
+
|
|
62
|
+
def test_history_learns_and_surfaces_facts():
|
|
63
|
+
mem = Memory()
|
|
64
|
+
h = AttestariChatMessageHistory(mem, subject_id="alice")
|
|
65
|
+
h.add_messages([HumanMessage("I live in Toronto.")])
|
|
66
|
+
h.add_messages([AIMessage("Noted.")])
|
|
67
|
+
msgs = h.messages
|
|
68
|
+
assert len(msgs) == 1 and "Toronto" in msgs[0].content
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_history_empty_when_no_facts():
|
|
72
|
+
h = AttestariChatMessageHistory(Memory(), subject_id="nobody")
|
|
73
|
+
assert h.messages == []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_history_clear_is_provable_deletion():
|
|
77
|
+
mem = Memory()
|
|
78
|
+
h = AttestariChatMessageHistory(mem, subject_id="alice")
|
|
79
|
+
h.add_messages([HumanMessage("I live in Toronto.")])
|
|
80
|
+
h.clear()
|
|
81
|
+
assert h.messages == []
|
|
82
|
+
assert AttestariRetriever(mem=mem, subject_id="alice").invoke("where?") == []
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_subject_isolation():
|
|
86
|
+
mem = Memory()
|
|
87
|
+
AttestariChatMessageHistory(mem, "alice").add_messages([HumanMessage("I live in Toronto.")])
|
|
88
|
+
AttestariChatMessageHistory(mem, "bob").add_messages([HumanMessage("I live in Berlin.")])
|
|
89
|
+
bob_docs = AttestariRetriever(mem=mem, subject_id="bob").invoke("where does the user live?")
|
|
90
|
+
blob = " ".join(d.page_content for d in bob_docs)
|
|
91
|
+
assert "Berlin" in blob and "Toronto" not in blob
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_history_does_not_learn_from_ai_messages():
|
|
95
|
+
"""Assistant turns are conversation, not testimony: a first-person pattern
|
|
96
|
+
in an AIMessage must not mint a fact attributed to the user."""
|
|
97
|
+
mem = Memory()
|
|
98
|
+
h = AttestariChatMessageHistory(mem, subject_id="alice")
|
|
99
|
+
h.add_messages([AIMessage("I moved to Berlin and I work at Globex.")])
|
|
100
|
+
assert h.messages == [] # nothing was learned
|
|
101
|
+
assert mem.timeline(subject_id="alice") == []
|
|
102
|
+
h.add_messages([HumanMessage("I live in Toronto.")])
|
|
103
|
+
blob = " ".join(m.content for m in h.messages)
|
|
104
|
+
assert "Toronto" in blob and "Berlin" not in blob
|