ragleap-memory 0.1.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.
- ragleap_memory-0.1.0/.gitignore +40 -0
- ragleap_memory-0.1.0/CHANGELOG.md +28 -0
- ragleap_memory-0.1.0/PKG-INFO +62 -0
- ragleap_memory-0.1.0/README.md +38 -0
- ragleap_memory-0.1.0/docs/threat-model.md +31 -0
- ragleap_memory-0.1.0/pyproject.toml +40 -0
- ragleap_memory-0.1.0/scripts/mutation_check.py +92 -0
- ragleap_memory-0.1.0/src/ragleap_memory/__init__.py +13 -0
- ragleap_memory-0.1.0/src/ragleap_memory/model.py +63 -0
- ragleap_memory-0.1.0/src/ragleap_memory/sqlite_store.py +322 -0
- ragleap_memory-0.1.0/tests/conftest.py +10 -0
- ragleap_memory-0.1.0/tests/test_add_validation.py +96 -0
- ragleap_memory-0.1.0/tests/test_durability.py +103 -0
- ragleap_memory-0.1.0/tests/test_isolation.py +107 -0
- ragleap_memory-0.1.0/tests/test_lifecycle.py +103 -0
- ragleap_memory-0.1.0/tests/test_search.py +111 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
.env
|
|
3
|
+
*.env.local
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*.egg-info/
|
|
9
|
+
venv/
|
|
10
|
+
.venv/
|
|
11
|
+
|
|
12
|
+
# Node
|
|
13
|
+
node_modules/
|
|
14
|
+
|
|
15
|
+
# Docker
|
|
16
|
+
docker-compose.override.yml
|
|
17
|
+
|
|
18
|
+
# IDE
|
|
19
|
+
.vscode/
|
|
20
|
+
.idea/
|
|
21
|
+
|
|
22
|
+
# OS
|
|
23
|
+
.DS_Store
|
|
24
|
+
Thumbs.db
|
|
25
|
+
|
|
26
|
+
# Logs
|
|
27
|
+
*.log
|
|
28
|
+
.env
|
|
29
|
+
|
|
30
|
+
# Package build artifacts
|
|
31
|
+
dist/
|
|
32
|
+
build/
|
|
33
|
+
|
|
34
|
+
# RagLeap command
|
|
35
|
+
.ragleap-ollama
|
|
36
|
+
backups/
|
|
37
|
+
|
|
38
|
+
# Folder grants are the owner's private choices
|
|
39
|
+
grants/*
|
|
40
|
+
!grants/README.txt
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `ragleap-memory` are documented here. Format
|
|
4
|
+
loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - 2026-10-23
|
|
7
|
+
|
|
8
|
+
First version (design: #900). It replaces an untracked prototype that was never committed, so there are no behaviour changes.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `SQLiteStore`: durable memory in one SQLite file with FTS5 keyword search.
|
|
13
|
+
- `add`, `get`, `search`, `supersede`, `forget`, `export`, `erase`, `sweep_expired`, `compact`, `count`, all scoped by a ragleap-security `Scope` inside the SQL.
|
|
14
|
+
- Typed entries (`episode`, `fact`, `summary`) with trust labels (`untrusted` by default), expiry, and links to the entries they were derived from.
|
|
15
|
+
- Optional audit log (ids and counts only, written inside the transaction).
|
|
16
|
+
- `docs/threat-model.md` and `scripts/mutation_check.py` (27 mutations).
|
|
17
|
+
|
|
18
|
+
### Verified
|
|
19
|
+
|
|
20
|
+
- 89 tests pass on Python 3.10 (the VPS, SQLite 3.37.2) and on Python 3.12 (the author's sandbox). The CI test job passes on Python 3.10, 3.11 and 3.12 (#922). All 27 mutations are killed on Python 3.10 and 3.12.
|
|
21
|
+
- After `erase()`, a byte-level search of the database file and its journal finds no trace of the deleted text (tested on the two SQLite builds above).
|
|
22
|
+
|
|
23
|
+
### Not verified
|
|
24
|
+
|
|
25
|
+
- Windows, network file systems, several processes writing one file, and power-loss durability.
|
|
26
|
+
- Retrieval quality: no benchmark has been run. Multilingual precision is unmeasured (whole-word search is tested for a few scripts).
|
|
27
|
+
- The published wheel had not been installed from PyPI when this was written (the GitHub Release records that check).
|
|
28
|
+
- No independent security review has been done.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ragleap-memory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Durable, tenant-scoped long-term memory for LLM applications: typed entries with trust labels, SQLite full-text search, supersession of changed facts, per-scope export and erase, and an optional tamper-evident audit log. Standard library plus ragleap-security. Single-tenant is one fixed scope. Not independently audited.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ragleap/ragleap-core
|
|
6
|
+
Project-URL: Repository, https://github.com/ragleap/ragleap-core
|
|
7
|
+
Project-URL: Issues, https://github.com/ragleap/ragleap-core/issues
|
|
8
|
+
Author: RagLeap
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: byok,fts5,llm,long-term-memory,memory,multi-tenant,self-hosted,sqlite
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: ragleap-security>=0.1.0
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# ragleap-memory
|
|
26
|
+
|
|
27
|
+
Durable, tenant-scoped long-term memory for LLM applications. Alpha. **Not independently audited**: see `docs/threat-model.md` for exactly what is and is not defended. Single-tenant use is simply one fixed `Scope`.
|
|
28
|
+
|
|
29
|
+
## What is in 0.1.0
|
|
30
|
+
|
|
31
|
+
- `SQLiteStore`: one SQLite file (or memory), FTS5 keyword search, no server. Needs SQLite built with FTS5; the store refuses to start without it.
|
|
32
|
+
- Every call takes a `Scope` (from ragleap-security) and is filtered by it inside the SQL. A tenant-wide scope sees its whole tenant; a narrower scope sees only its own subject, agent and session.
|
|
33
|
+
- Typed entries (`episode`, `fact`, `summary`) with a trust label (`untrusted`, `model`, `owner`). New entries default to `untrusted`.
|
|
34
|
+
- `supersede` for facts that change (history is kept), `forget`, `export`, `erase` (removes the text from the file), `sweep_expired`, and an optional `AuditLog` that records operations without entry text.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from ragleap_memory import SQLiteStore
|
|
38
|
+
from ragleap_security import Scope
|
|
39
|
+
|
|
40
|
+
store = SQLiteStore("memory.db")
|
|
41
|
+
alice = Scope("acme", subject="alice")
|
|
42
|
+
|
|
43
|
+
old = store.add(alice, "Alice prefers email", trust="owner")
|
|
44
|
+
new = store.supersede(alice, old.id, "Alice prefers phone calls", trust="owner")
|
|
45
|
+
|
|
46
|
+
for hit in store.search(alice, "how should we contact alice", k=3, min_trust="model"):
|
|
47
|
+
print(hit.entry.trust, hit.entry.text)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The store labels trust but does not decide what to do with retrieved text. A consumer that puts memory into a prompt must treat anything below `owner` as untrusted input.
|
|
51
|
+
|
|
52
|
+
## Verifying it yourself
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
pip install -e ../ragleap-security -e ".[test]"
|
|
56
|
+
pytest
|
|
57
|
+
python scripts/mutation_check.py # breaks one protection at a time; every mutation must be killed
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Not in 0.1.0
|
|
61
|
+
|
|
62
|
+
Embeddings and vector search, consolidation and retention policies, importance-based ranking, Postgres, encryption at rest, a review queue and the ragleap-agents adapter are planned (issue #900). Search quality has not been measured on any benchmark.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# ragleap-memory
|
|
2
|
+
|
|
3
|
+
Durable, tenant-scoped long-term memory for LLM applications. Alpha. **Not independently audited**: see `docs/threat-model.md` for exactly what is and is not defended. Single-tenant use is simply one fixed `Scope`.
|
|
4
|
+
|
|
5
|
+
## What is in 0.1.0
|
|
6
|
+
|
|
7
|
+
- `SQLiteStore`: one SQLite file (or memory), FTS5 keyword search, no server. Needs SQLite built with FTS5; the store refuses to start without it.
|
|
8
|
+
- Every call takes a `Scope` (from ragleap-security) and is filtered by it inside the SQL. A tenant-wide scope sees its whole tenant; a narrower scope sees only its own subject, agent and session.
|
|
9
|
+
- Typed entries (`episode`, `fact`, `summary`) with a trust label (`untrusted`, `model`, `owner`). New entries default to `untrusted`.
|
|
10
|
+
- `supersede` for facts that change (history is kept), `forget`, `export`, `erase` (removes the text from the file), `sweep_expired`, and an optional `AuditLog` that records operations without entry text.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from ragleap_memory import SQLiteStore
|
|
14
|
+
from ragleap_security import Scope
|
|
15
|
+
|
|
16
|
+
store = SQLiteStore("memory.db")
|
|
17
|
+
alice = Scope("acme", subject="alice")
|
|
18
|
+
|
|
19
|
+
old = store.add(alice, "Alice prefers email", trust="owner")
|
|
20
|
+
new = store.supersede(alice, old.id, "Alice prefers phone calls", trust="owner")
|
|
21
|
+
|
|
22
|
+
for hit in store.search(alice, "how should we contact alice", k=3, min_trust="model"):
|
|
23
|
+
print(hit.entry.trust, hit.entry.text)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The store labels trust but does not decide what to do with retrieved text. A consumer that puts memory into a prompt must treat anything below `owner` as untrusted input.
|
|
27
|
+
|
|
28
|
+
## Verifying it yourself
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install -e ../ragleap-security -e ".[test]"
|
|
32
|
+
pytest
|
|
33
|
+
python scripts/mutation_check.py # breaks one protection at a time; every mutation must be killed
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Not in 0.1.0
|
|
37
|
+
|
|
38
|
+
Embeddings and vector search, consolidation and retention policies, importance-based ranking, Postgres, encryption at rest, a review queue and the ragleap-agents adapter are planned (issue #900). Search quality has not been measured on any benchmark.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# ragleap-memory 0.1.0: threat model
|
|
2
|
+
|
|
3
|
+
Status: first version. It covers only what 0.1.0 contains. Anything not listed as defended is not defended. This library has had no independent security review.
|
|
4
|
+
|
|
5
|
+
## Scope of 0.1.0
|
|
6
|
+
|
|
7
|
+
`SQLiteStore`: durable memory in one SQLite file (or in memory) with FTS5 keyword search, typed entries with trust labels, supersession, per-scope export and erase, and an optional audit log. It uses `Scope`, `AuditLog` and the limits from ragleap-security 0.1.0. Nothing in `core/` or ragleap-rag uses it yet.
|
|
8
|
+
|
|
9
|
+
## Threats
|
|
10
|
+
|
|
11
|
+
| # | Threat | What 0.1.0 does | Backed by | Not defended |
|
|
12
|
+
|---|--------|-----------------|-----------|--------------|
|
|
13
|
+
| 1 | One tenant reads, changes or deletes another tenant's entries | Every operation requires an exact `Scope`. The scope is applied inside the SQL (tenant plus any narrower subject, agent and session). `derived_from` ids must be visible to the writer's scope. A missing entry and an invisible entry give the same error. | `tests/test_isolation.py`, `tests/test_add_validation.py`, `tests/test_lifecycle.py`; 11 mutations in `scripts/mutation_check.py` | The caller chooses the scope. The store cannot know whether a caller is entitled to it: authentication and mapping users to scopes belong to the application. Anyone who can read the database file or hold the store object bypasses it. A tenant-wide scope sees everything in its tenant by design. |
|
|
14
|
+
| 2 | Untrusted or poisoned text is later treated as trustworthy | Every entry carries a trust label (`untrusted`, `model`, `owner`); the default is the lowest; `min_trust` filters searches; derived entries record their sources. | `tests/test_search.py`, `tests/test_add_validation.py`; 2 mutations | Labels are only as honest as whoever writes them: a writer can claim `owner`. The store does not stop a consumer from putting retrieved text into a prompt as instructions. Defending against prompt injection through retrieved memory is the consumer's job (the ragleap-agents integration is planned). No content scanning. |
|
|
15
|
+
| 3 | A search query changes the query, or is used to exhaust resources | Queries are reduced to quoted words joined with OR, so operators are plain words; at most 32 words and 1,000 bytes; results are capped (default 50). | `tests/test_search.py`, `tests/test_isolation.py`; 3 mutations | A slow query inside these limits on a very large store. No timing has been measured. |
|
|
16
|
+
| 4 | Deleted text stays in the database file | `erase()` deletes, rewrites the full-text index, and checkpoints the journal; `secure_delete` is turned on explicitly. A byte-level test checks the text is gone from the file. | `tests/test_lifecycle.py`; 2 mutations | `forget()` leaves traces in the search index until `compact()` or `erase()` runs (a test pins this). Other SQLite builds, other operating systems, file system journals, SSD wear levelling, backups, snapshots and copies of the file are **not verified**. |
|
|
17
|
+
| 5 | A change happens without a record, or half-happens | The optional `AuditLog` is written inside the transaction, so a failing audit write rolls the change back; the log holds ids and counts, never entry text; any error rolls the whole write back. | `tests/test_durability.py`; 2 mutations | The audit log is optional and off by default. Reads (`get`, `search`, `count`) are not audited, only `export`. The log is tamper-evident, not tamper-proof (see the ragleap-security threat model). |
|
|
18
|
+
| 6 | Oversized input exhausts memory or disk | Text is capped at 8,192 bytes; metadata at 4,096 bytes, depth 8 and 1,000 values; `derived_from` at 100 ids; `forget` at 1,000 ids. | `tests/test_add_validation.py`; 2 mutations | No cap on the number of entries per tenant or on the file size: a tenant can fill the disk. No rate limiting. |
|
|
19
|
+
| 7 | The database file is readable by other local users | The file is created with mode 0600. Checked by hand on Linux, SQLite's `-wal` and `-shm` files were 0600 too; the test checks only the main file. | `tests/test_durability.py`; 1 mutation | A file that already exists keeps its permissions. No encryption at rest (planned with ragleap-security `crypto`, not in 0.1.0). Backups. |
|
|
20
|
+
| 8 | A newer schema is opened by older code, or writers collide | A database with a newer schema version is refused; writes run in `BEGIN IMMEDIATE` transactions with WAL; threads share one locked connection. | `tests/test_durability.py` (8 threads, 200 writes); 1 mutation | Several processes writing the same file, network file systems and power-loss durability beyond SQLite's defaults are **not verified**. |
|
|
21
|
+
| 9 | An outdated or expired fact is used as if current | Expired and superseded entries are hidden from search unless asked; `supersede` links the new entry to the old one and refuses a second supersession. | `tests/test_search.py`, `tests/test_lifecycle.py`; 3 mutations | The store cannot know a fact is wrong. Two contradicting entries added with `add()` both stay current. |
|
|
22
|
+
|
|
23
|
+
## Not covered in 0.1.0
|
|
24
|
+
|
|
25
|
+
Encryption at rest, embeddings and vector search, consolidation, retention and decay beyond expiry, importance-based ranking (importance is stored but does not affect ranking yet), Postgres, a review queue, and the ragleap-agents adapter. Retrieval quality has not been measured on any benchmark.
|
|
26
|
+
|
|
27
|
+
Languages: search uses SQLite's `unicode61` tokenizer. Whole-word search is tested for Hindi, Tamil, Arabic and Chinese written with spaces. Chinese or Japanese text without spaces is not split into words. Words in Indic scripts are split at combining marks, so partial matches can be loose; precision has not been measured.
|
|
28
|
+
|
|
29
|
+
## How this document is checked
|
|
30
|
+
|
|
31
|
+
`python scripts/mutation_check.py` breaks one protection at a time in a temporary copy and requires a test to fail. All 27 listed mutations were killed when this version was written. A killed mutation shows a protection is exercised by a test; it does not show the protection is sufficient.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ragleap-memory"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Durable, tenant-scoped long-term memory for LLM applications: typed entries with trust labels, SQLite full-text search, supersession of changed facts, per-scope export and erase, and an optional tamper-evident audit log. Standard library plus ragleap-security. Single-tenant is one fixed scope. Not independently audited."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "RagLeap" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["memory", "llm", "long-term-memory", "multi-tenant", "sqlite", "fts5", "self-hosted", "byok"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
dependencies = [
|
|
28
|
+
"ragleap-security>=0.1.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
test = ["pytest>=8.0.0"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/ragleap/ragleap-core"
|
|
36
|
+
Repository = "https://github.com/ragleap/ragleap-core"
|
|
37
|
+
Issues = "https://github.com/ragleap/ragleap-core/issues"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/ragleap_memory"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mutation check for ragleap-memory (repository only, not part of the installed package).
|
|
3
|
+
|
|
4
|
+
For each mutation below it copies the package to a temp directory, breaks one protection in the
|
|
5
|
+
copy, and runs the test suite. A mutation is KILLED if a test fails, SURVIVED if every test still
|
|
6
|
+
passes (meaning that protection is not really tested). The real source is never modified.
|
|
7
|
+
|
|
8
|
+
python scripts/mutation_check.py
|
|
9
|
+
|
|
10
|
+
Needs ragleap-security importable (installed, or on PYTHONPATH). Exit code 0 only if the unmodified
|
|
11
|
+
suite passes and every mutation is killed. Run from the package directory. A killed mutation shows a
|
|
12
|
+
protection is exercised by a test; it does not show the protection is sufficient."""
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
PKG = Path(__file__).resolve().parent.parent
|
|
21
|
+
S = "src/ragleap_memory/sqlite_store.py"
|
|
22
|
+
|
|
23
|
+
# (name, [(file, old, new), ...]) ; each old string must occur exactly once in its file
|
|
24
|
+
MUTATIONS = [
|
|
25
|
+
("scope clause ignores the tenant", [(S, 'parts, params = [f"{p}tenant = ?"], [scope.tenant]', 'parts, params = ["1 = 1"], []')]),
|
|
26
|
+
("scope clause ignores narrower parts", [(S, " if v is not None:\n parts.append(", " if False:\n parts.append(")]),
|
|
27
|
+
("get checks ids but not the scope", [(S, 'row = db.execute(f"SELECT * FROM memories WHERE id = ? AND {clause}", [entry_id, *params]).fetchone()', 'row = db.execute("SELECT * FROM memories WHERE id = ?", [entry_id]).fetchone()')]),
|
|
28
|
+
("search drops the scope condition", [(S, 'f" WHERE memories_fts MATCH ? AND {clause}"', 'f" WHERE memories_fts MATCH ? AND 1 = 1"'), (S, "args: list = [match, *params, *kinds, *trusts]", "args: list = [match, *kinds, *trusts]")]),
|
|
29
|
+
("forget deletes by id only", [(S, 'n = db.execute(f"DELETE FROM memories WHERE id IN ({\',\'.join(\'?\' * len(ids))}) AND {clause}",\n [*ids, *params]).rowcount', 'n = db.execute(f"DELETE FROM memories WHERE id IN ({\',\'.join(\'?\' * len(ids))})",\n [*ids]).rowcount')]),
|
|
30
|
+
("erase deletes everything", [(S, 'n = db.execute(f"DELETE FROM memories WHERE {clause}", params).rowcount\n self._log("memory.erase"', 'n = db.execute("DELETE FROM memories").rowcount\n self._log("memory.erase"')]),
|
|
31
|
+
("sweep ignores the scope", [(S, 'n = db.execute(f"DELETE FROM memories WHERE {clause} AND expires_at IS NOT NULL AND expires_at <= ?",\n [*params, _iso(self._clock())]).rowcount', 'n = db.execute("DELETE FROM memories WHERE expires_at IS NOT NULL AND expires_at <= ?",\n [_iso(self._clock())]).rowcount')]),
|
|
32
|
+
("export ignores the scope", [(S, 'rows = db.execute(f"SELECT * FROM memories WHERE {clause} ORDER BY created_at, id", params).fetchall()', 'rows = db.execute("SELECT * FROM memories ORDER BY created_at, id").fetchall()')]),
|
|
33
|
+
("count ignores the scope", [(S, 'return self._db.execute(f"SELECT COUNT(*) FROM memories WHERE {clause}", params).fetchone()[0]', 'return self._db.execute("SELECT COUNT(*) FROM memories").fetchone()[0]')]),
|
|
34
|
+
("derived_from not checked against the scope", [(S, " for d in derived:\n self._visible(db, scope, d)\n", " for d in derived:\n pass\n")]),
|
|
35
|
+
("supersede does not check the scope", [(S, " old = self._visible(db, scope, old_id)\n", ' old = db.execute("SELECT * FROM memories WHERE id = ?", [old_id]).fetchone()\n if old is None:\n raise NotFoundError("no such entry")\n')]),
|
|
36
|
+
("double supersede allowed", [(S, 'if old["superseded_by"] is not None:', "if False:")]),
|
|
37
|
+
("default trust raised to owner", [(S, 'source: str = "api", trust: str = "untrusted",\n importance: float = 0.5', 'source: str = "api", trust: str = "owner",\n importance: float = 0.5')]),
|
|
38
|
+
("min_trust ignored", [(S, "trusts = [t for t in TRUSTS if trust_rank(t) >= trust_rank(min_trust)]", "trusts = list(TRUSTS)")]),
|
|
39
|
+
("expired entries not hidden", [(S, " if not include_expired:\n sql +=", " if False:\n sql +=")]),
|
|
40
|
+
("superseded entries not hidden", [(S, " if not include_superseded:\n sql +=", " if False:\n sql +=")]),
|
|
41
|
+
("query words not quoted", [(S, """match = " OR ".join('"%s"' % t for t in tokens)""", """match = " OR ".join(tokens)""")]),
|
|
42
|
+
("query size not limited", [(S, 'check_size(query, 1000, "query")', "pass")]),
|
|
43
|
+
("result cap removed", [(S, "k = min(k, self.max_results)", "pass")]),
|
|
44
|
+
("text size not limited", [(S, "check_size(text, self.max_text_bytes, \"text\")", "pass")]),
|
|
45
|
+
("metadata shape not limited", [(S, "check_json_shape(metadata, max_depth=8, max_nodes=1000)", "pass")]),
|
|
46
|
+
("audit written after the transaction (not rolled back)", [(S, " if self._audit is not None: # inside the transaction: if the audit write fails, the change is rolled back\n self._audit.append(event, data, scope)", " pass")]),
|
|
47
|
+
("secure_delete explicitly off", [(S, 'self._db.execute("PRAGMA secure_delete = ON")', 'self._db.execute("PRAGMA secure_delete = OFF")')]),
|
|
48
|
+
("erase does not compact", [(S, " self._log(\"memory.erase\", scope, {\"count\": n})\n self.compact()", " self._log(\"memory.erase\", scope, {\"count\": n})")]),
|
|
49
|
+
("database file not private", [(S, "os.O_WRONLY | os.O_CREAT, 0o600", "os.O_WRONLY | os.O_CREAT, 0o644")]),
|
|
50
|
+
("newer schema accepted", [(S, "if version > SCHEMA_VERSION:", "if False:")]),
|
|
51
|
+
("failed write not rolled back", [(S, ' self._db.execute("ROLLBACK")\n raise', " raise")]),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_tests(root: Path) -> bool:
|
|
56
|
+
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1",
|
|
57
|
+
PYTHONPATH=os.pathsep.join(p for p in (str(root / "src"), os.environ.get("PYTHONPATH", "")) if p))
|
|
58
|
+
r = subprocess.run([sys.executable, "-m", "pytest", "-q", "-x", "-p", "no:cacheprovider", str(root / "tests")],
|
|
59
|
+
cwd=root, env=env, capture_output=True, text=True)
|
|
60
|
+
return r.returncode == 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main() -> int:
|
|
64
|
+
survivors = []
|
|
65
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
66
|
+
base = Path(tmp) / "base"
|
|
67
|
+
shutil.copytree(PKG, base, ignore=shutil.ignore_patterns("__pycache__", ".pytest_cache", "scripts"))
|
|
68
|
+
if not run_tests(base):
|
|
69
|
+
print("The unmodified test suite fails; fix that first.")
|
|
70
|
+
return 2
|
|
71
|
+
print("baseline: tests pass")
|
|
72
|
+
for i, (name, edits) in enumerate(MUTATIONS):
|
|
73
|
+
work = Path(tmp) / f"m{i}"
|
|
74
|
+
shutil.copytree(base, work)
|
|
75
|
+
for rel, old, new in edits:
|
|
76
|
+
p = work / rel
|
|
77
|
+
text = p.read_text()
|
|
78
|
+
if text.count(old) != 1:
|
|
79
|
+
print(f"ERROR {name}: anchor not found exactly once in {rel}")
|
|
80
|
+
return 2
|
|
81
|
+
p.write_text(text.replace(old, new))
|
|
82
|
+
killed = not run_tests(work)
|
|
83
|
+
print(("KILLED " if killed else "SURVIVED ") + name)
|
|
84
|
+
if not killed:
|
|
85
|
+
survivors.append(name)
|
|
86
|
+
shutil.rmtree(work)
|
|
87
|
+
print(f"\n{len(MUTATIONS) - len(survivors)} of {len(MUTATIONS)} mutations killed")
|
|
88
|
+
return 1 if survivors else 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
sys.exit(main())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""ragleap-memory: durable, tenant-scoped long-term memory for LLM applications.
|
|
2
|
+
|
|
3
|
+
Needs SQLite with FTS5. Not independently audited. See docs/threat-model.md."""
|
|
4
|
+
from ragleap_memory.model import (KINDS, TRUSTS, InvalidEntryError, MemoryEntry, MemoryStoreError, NotFoundError,
|
|
5
|
+
SearchHit)
|
|
6
|
+
from ragleap_memory.sqlite_store import SCHEMA_VERSION, SQLiteStore
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"InvalidEntryError", "KINDS", "MemoryEntry", "MemoryStoreError", "NotFoundError", "SCHEMA_VERSION",
|
|
12
|
+
"SQLiteStore", "SearchHit", "TRUSTS", "__version__",
|
|
13
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Data model for ragleap-memory: entries, search hits, trust labels and errors."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, Tuple
|
|
7
|
+
|
|
8
|
+
from ragleap_security import Scope
|
|
9
|
+
|
|
10
|
+
KINDS = ("episode", "fact", "summary")
|
|
11
|
+
# Lowest to highest. "owner": written by the owner. "model": written by a model. "untrusted": derived
|
|
12
|
+
# from content nobody vouched for. The default for new entries is the lowest.
|
|
13
|
+
TRUSTS = ("untrusted", "model", "owner")
|
|
14
|
+
|
|
15
|
+
ID_RE = re.compile(r"[0-9a-f]{32}\Z")
|
|
16
|
+
SOURCE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,127}\Z")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MemoryStoreError(Exception):
|
|
20
|
+
"""Base class for memory store errors. Messages never contain entry text."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidEntryError(MemoryStoreError, ValueError):
|
|
24
|
+
"""An entry or argument is invalid."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NotFoundError(MemoryStoreError):
|
|
28
|
+
"""No such entry, or it is not visible to the scope that asked. The two cases look the same."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def trust_rank(trust: str) -> int:
|
|
32
|
+
return TRUSTS.index(trust)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class MemoryEntry:
|
|
37
|
+
id: str
|
|
38
|
+
scope: Scope
|
|
39
|
+
kind: str
|
|
40
|
+
text: str
|
|
41
|
+
source: str
|
|
42
|
+
trust: str
|
|
43
|
+
importance: float
|
|
44
|
+
created_at: str
|
|
45
|
+
expires_at: "str | None" = None
|
|
46
|
+
superseded_by: "str | None" = None
|
|
47
|
+
derived_from: Tuple[str, ...] = ()
|
|
48
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"id": self.id, "scope": self.scope.key, "kind": self.kind, "text": self.text,
|
|
53
|
+
"source": self.source, "trust": self.trust, "importance": self.importance,
|
|
54
|
+
"created_at": self.created_at, "expires_at": self.expires_at,
|
|
55
|
+
"superseded_by": self.superseded_by, "derived_from": list(self.derived_from),
|
|
56
|
+
"metadata": self.metadata,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class SearchHit:
|
|
62
|
+
entry: MemoryEntry
|
|
63
|
+
score: float # higher is more relevant (negated SQLite bm25)
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""SQLiteStore: durable memory in one SQLite file (or in memory), with FTS5 keyword search.
|
|
2
|
+
|
|
3
|
+
Every operation takes a Scope and is filtered by it inside the SQL, not by the caller. A scope sees
|
|
4
|
+
the entries whose scope it covers: a tenant-wide scope sees everything in the tenant, a narrower scope
|
|
5
|
+
sees only its own subject/agent/session, and an entry without a subject is not visible to a scope that
|
|
6
|
+
names one."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sqlite3
|
|
14
|
+
import threading
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Sequence
|
|
18
|
+
|
|
19
|
+
from ragleap_security import (AuditLog, Scope, check_json_shape, check_size, require_scope)
|
|
20
|
+
|
|
21
|
+
from .model import (ID_RE, KINDS, SOURCE_RE, TRUSTS, InvalidEntryError, MemoryEntry, MemoryStoreError,
|
|
22
|
+
NotFoundError, SearchHit, trust_rank)
|
|
23
|
+
|
|
24
|
+
SCHEMA_VERSION = 1
|
|
25
|
+
_SCOPE_FIELDS = ("tenant", "subject", "agent", "session")
|
|
26
|
+
_TOKEN = re.compile(r"\w+")
|
|
27
|
+
|
|
28
|
+
_SCHEMA = """
|
|
29
|
+
CREATE TABLE IF NOT EXISTS memories(
|
|
30
|
+
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
31
|
+
id TEXT NOT NULL UNIQUE,
|
|
32
|
+
tenant TEXT NOT NULL, subject TEXT, agent TEXT, session TEXT,
|
|
33
|
+
kind TEXT NOT NULL CHECK(kind IN ('episode','fact','summary')),
|
|
34
|
+
text TEXT NOT NULL,
|
|
35
|
+
source TEXT NOT NULL,
|
|
36
|
+
trust TEXT NOT NULL CHECK(trust IN ('untrusted','model','owner')),
|
|
37
|
+
importance REAL NOT NULL CHECK(importance >= 0 AND importance <= 1),
|
|
38
|
+
created_at TEXT NOT NULL,
|
|
39
|
+
expires_at TEXT,
|
|
40
|
+
superseded_by TEXT,
|
|
41
|
+
derived_from TEXT NOT NULL,
|
|
42
|
+
metadata TEXT NOT NULL
|
|
43
|
+
);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS memories_scope ON memories(tenant, subject, agent, session);
|
|
45
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
46
|
+
text, content='memories', content_rowid='rowid', tokenize='unicode61 remove_diacritics 2');
|
|
47
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
48
|
+
INSERT INTO memories_fts(rowid, text) VALUES (new.rowid, new.text);
|
|
49
|
+
END;
|
|
50
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
51
|
+
INSERT INTO memories_fts(memories_fts, rowid, text) VALUES ('delete', old.rowid, old.text);
|
|
52
|
+
END;
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _iso(dt: datetime) -> str:
|
|
57
|
+
return dt.astimezone(timezone.utc).isoformat(timespec="microseconds")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _scope_clause(scope: Scope, alias: str = "") -> "tuple[str, list]":
|
|
61
|
+
"""SQL condition that matches exactly the entries `scope` covers."""
|
|
62
|
+
p = f"{alias}." if alias else ""
|
|
63
|
+
parts, params = [f"{p}tenant = ?"], [scope.tenant]
|
|
64
|
+
for f in _SCOPE_FIELDS[1:]:
|
|
65
|
+
v = getattr(scope, f)
|
|
66
|
+
if v is not None:
|
|
67
|
+
parts.append(f"{p}{f} = ?")
|
|
68
|
+
params.append(v)
|
|
69
|
+
return " AND ".join(parts), params
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _row_to_entry(row: sqlite3.Row) -> MemoryEntry:
|
|
73
|
+
return MemoryEntry(
|
|
74
|
+
id=row["id"],
|
|
75
|
+
scope=Scope(row["tenant"], row["subject"], row["agent"], row["session"]),
|
|
76
|
+
kind=row["kind"], text=row["text"], source=row["source"], trust=row["trust"],
|
|
77
|
+
importance=row["importance"], created_at=row["created_at"], expires_at=row["expires_at"],
|
|
78
|
+
superseded_by=row["superseded_by"], derived_from=tuple(json.loads(row["derived_from"])),
|
|
79
|
+
metadata=json.loads(row["metadata"]),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SQLiteStore:
|
|
84
|
+
def __init__(self, path: str = ":memory:", *, audit: Optional[AuditLog] = None,
|
|
85
|
+
clock: Optional[Callable[[], datetime]] = None, max_text_bytes: int = 8192,
|
|
86
|
+
max_results: int = 50):
|
|
87
|
+
self.path = os.fspath(path)
|
|
88
|
+
self._audit = audit
|
|
89
|
+
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
|
90
|
+
self.max_text_bytes = max_text_bytes
|
|
91
|
+
self.max_results = max_results
|
|
92
|
+
self._lock = threading.RLock()
|
|
93
|
+
if self.path != ":memory:" and not os.path.exists(self.path):
|
|
94
|
+
os.close(os.open(self.path, os.O_WRONLY | os.O_CREAT, 0o600))
|
|
95
|
+
self._db = sqlite3.connect(self.path, check_same_thread=False, isolation_level=None)
|
|
96
|
+
self._db.row_factory = sqlite3.Row
|
|
97
|
+
self._db.execute("PRAGMA busy_timeout = 5000")
|
|
98
|
+
self._db.execute("PRAGMA secure_delete = ON")
|
|
99
|
+
if self.path != ":memory:":
|
|
100
|
+
self._db.execute("PRAGMA journal_mode = WAL")
|
|
101
|
+
version = self._db.execute("PRAGMA user_version").fetchone()[0]
|
|
102
|
+
if version > SCHEMA_VERSION:
|
|
103
|
+
self._db.close()
|
|
104
|
+
raise MemoryStoreError(f"database schema version {version} is newer than this library supports")
|
|
105
|
+
try:
|
|
106
|
+
self._db.executescript(_SCHEMA)
|
|
107
|
+
except sqlite3.OperationalError as e:
|
|
108
|
+
self._db.close()
|
|
109
|
+
raise MemoryStoreError("SQLite with FTS5 is required: %s" % e) from None
|
|
110
|
+
self._db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
111
|
+
|
|
112
|
+
# -- helpers -----------------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
@contextlib.contextmanager
|
|
115
|
+
def _tx(self) -> Iterator[sqlite3.Connection]:
|
|
116
|
+
with self._lock:
|
|
117
|
+
self._db.execute("BEGIN IMMEDIATE")
|
|
118
|
+
try:
|
|
119
|
+
yield self._db
|
|
120
|
+
except BaseException:
|
|
121
|
+
self._db.execute("ROLLBACK")
|
|
122
|
+
raise
|
|
123
|
+
else:
|
|
124
|
+
self._db.execute("COMMIT")
|
|
125
|
+
|
|
126
|
+
def _log(self, event: str, scope: Scope, data: Dict[str, Any]) -> None:
|
|
127
|
+
if self._audit is not None: # inside the transaction: if the audit write fails, the change is rolled back
|
|
128
|
+
self._audit.append(event, data, scope)
|
|
129
|
+
|
|
130
|
+
def _visible(self, db: sqlite3.Connection, scope: Scope, entry_id: str) -> sqlite3.Row:
|
|
131
|
+
clause, params = _scope_clause(scope)
|
|
132
|
+
row = db.execute(f"SELECT * FROM memories WHERE id = ? AND {clause}", [entry_id, *params]).fetchone()
|
|
133
|
+
if row is None:
|
|
134
|
+
raise NotFoundError("no such entry")
|
|
135
|
+
return row
|
|
136
|
+
|
|
137
|
+
def _validate_new(self, scope: Any, text: Any, kind: str, source: str, trust: str, importance: Any,
|
|
138
|
+
expires_at: Any, metadata: Any) -> "tuple[Scope, str | None, str]":
|
|
139
|
+
scope = require_scope(scope)
|
|
140
|
+
if kind not in KINDS:
|
|
141
|
+
raise InvalidEntryError("invalid kind")
|
|
142
|
+
if trust not in TRUSTS:
|
|
143
|
+
raise InvalidEntryError("invalid trust")
|
|
144
|
+
if not isinstance(source, str) or not SOURCE_RE.match(source):
|
|
145
|
+
raise InvalidEntryError("invalid source")
|
|
146
|
+
if isinstance(importance, bool) or not isinstance(importance, (int, float)) or not 0 <= importance <= 1:
|
|
147
|
+
raise InvalidEntryError("importance must be a number from 0 to 1")
|
|
148
|
+
if not isinstance(text, str) or not text.strip():
|
|
149
|
+
raise InvalidEntryError("text must be a non-empty string")
|
|
150
|
+
check_size(text, self.max_text_bytes, "text")
|
|
151
|
+
exp = None
|
|
152
|
+
if expires_at is not None:
|
|
153
|
+
if not isinstance(expires_at, datetime) or expires_at.tzinfo is None:
|
|
154
|
+
raise InvalidEntryError("expires_at must be a timezone-aware datetime")
|
|
155
|
+
exp = _iso(expires_at)
|
|
156
|
+
if metadata is None:
|
|
157
|
+
metadata = {}
|
|
158
|
+
if not isinstance(metadata, dict):
|
|
159
|
+
raise InvalidEntryError("metadata must be a dict")
|
|
160
|
+
check_json_shape(metadata, max_depth=8, max_nodes=1000)
|
|
161
|
+
try:
|
|
162
|
+
meta_json = json.dumps(metadata, sort_keys=True)
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
raise InvalidEntryError("metadata must be JSON serializable") from None
|
|
165
|
+
check_size(meta_json, 4096, "metadata")
|
|
166
|
+
return scope, exp, meta_json
|
|
167
|
+
|
|
168
|
+
def _insert(self, db: sqlite3.Connection, scope: Scope, text: str, kind: str, source: str, trust: str,
|
|
169
|
+
importance: float, exp: Optional[str], derived: Sequence[str], meta_json: str) -> MemoryEntry:
|
|
170
|
+
entry_id = uuid.uuid4().hex
|
|
171
|
+
created = _iso(self._clock())
|
|
172
|
+
db.execute(
|
|
173
|
+
"INSERT INTO memories(id, tenant, subject, agent, session, kind, text, source, trust, importance,"
|
|
174
|
+
" created_at, expires_at, superseded_by, derived_from, metadata) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?,?)",
|
|
175
|
+
[entry_id, scope.tenant, scope.subject, scope.agent, scope.session, kind, text, source, trust,
|
|
176
|
+
float(importance), created, exp, json.dumps(list(derived)), meta_json])
|
|
177
|
+
return _row_to_entry(db.execute("SELECT * FROM memories WHERE id = ?", [entry_id]).fetchone())
|
|
178
|
+
|
|
179
|
+
# -- public API --------------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def add(self, scope: Scope, text: str, *, kind: str = "fact", source: str = "api", trust: str = "untrusted",
|
|
182
|
+
importance: float = 0.5, expires_at: Optional[datetime] = None, derived_from: Iterable[str] = (),
|
|
183
|
+
metadata: Optional[Dict[str, Any]] = None) -> MemoryEntry:
|
|
184
|
+
"""Store an entry in exactly `scope`. trust defaults to the lowest level ("untrusted"): say
|
|
185
|
+
"model" or "owner" explicitly. Every id in derived_from must be visible to `scope`."""
|
|
186
|
+
scope, exp, meta_json = self._validate_new(scope, text, kind, source, trust, importance, expires_at, metadata)
|
|
187
|
+
derived = list(derived_from)
|
|
188
|
+
if len(derived) > 100 or len(set(derived)) != len(derived) or not all(isinstance(d, str) and ID_RE.match(d) for d in derived):
|
|
189
|
+
raise InvalidEntryError("derived_from must be up to 100 distinct entry ids")
|
|
190
|
+
with self._tx() as db:
|
|
191
|
+
for d in derived:
|
|
192
|
+
self._visible(db, scope, d)
|
|
193
|
+
entry = self._insert(db, scope, text, kind, source, trust, importance, exp, derived, meta_json)
|
|
194
|
+
self._log("memory.add", scope, {"id": entry.id, "kind": kind, "trust": trust})
|
|
195
|
+
return entry
|
|
196
|
+
|
|
197
|
+
def get(self, scope: Scope, entry_id: str) -> MemoryEntry:
|
|
198
|
+
scope = require_scope(scope)
|
|
199
|
+
with self._lock:
|
|
200
|
+
return _row_to_entry(self._visible(self._db, scope, entry_id))
|
|
201
|
+
|
|
202
|
+
def count(self, scope: Scope) -> int:
|
|
203
|
+
scope = require_scope(scope)
|
|
204
|
+
clause, params = _scope_clause(scope)
|
|
205
|
+
with self._lock:
|
|
206
|
+
return self._db.execute(f"SELECT COUNT(*) FROM memories WHERE {clause}", params).fetchone()[0]
|
|
207
|
+
|
|
208
|
+
def search(self, scope: Scope, query: str, *, k: int = 5, kinds: Optional[Sequence[str]] = None,
|
|
209
|
+
min_trust: str = "untrusted", include_superseded: bool = False,
|
|
210
|
+
include_expired: bool = False) -> List[SearchHit]:
|
|
211
|
+
"""Keyword search (FTS5, bm25) over the entries `scope` can see. The query is split into
|
|
212
|
+
words; any word may match. Operators in the query are treated as plain words."""
|
|
213
|
+
scope = require_scope(scope)
|
|
214
|
+
if not isinstance(query, str):
|
|
215
|
+
raise InvalidEntryError("query must be a string")
|
|
216
|
+
check_size(query, 1000, "query")
|
|
217
|
+
if isinstance(k, bool) or not isinstance(k, int) or k < 1:
|
|
218
|
+
raise InvalidEntryError("k must be a positive int")
|
|
219
|
+
k = min(k, self.max_results)
|
|
220
|
+
if min_trust not in TRUSTS:
|
|
221
|
+
raise InvalidEntryError("invalid min_trust")
|
|
222
|
+
kinds = tuple(kinds) if kinds is not None else KINDS
|
|
223
|
+
if not kinds or any(x not in KINDS for x in kinds):
|
|
224
|
+
raise InvalidEntryError("invalid kinds")
|
|
225
|
+
tokens = _TOKEN.findall(query)[:32]
|
|
226
|
+
if not tokens:
|
|
227
|
+
return []
|
|
228
|
+
match = " OR ".join('"%s"' % t for t in tokens)
|
|
229
|
+
clause, params = _scope_clause(scope, "m")
|
|
230
|
+
trusts = [t for t in TRUSTS if trust_rank(t) >= trust_rank(min_trust)]
|
|
231
|
+
sql = (f"SELECT m.*, bm25(memories_fts) AS score FROM memories_fts JOIN memories m ON m.rowid = memories_fts.rowid"
|
|
232
|
+
f" WHERE memories_fts MATCH ? AND {clause}"
|
|
233
|
+
f" AND m.kind IN ({','.join('?' * len(kinds))}) AND m.trust IN ({','.join('?' * len(trusts))})")
|
|
234
|
+
args: list = [match, *params, *kinds, *trusts]
|
|
235
|
+
if not include_superseded:
|
|
236
|
+
sql += " AND m.superseded_by IS NULL"
|
|
237
|
+
if not include_expired:
|
|
238
|
+
sql += " AND (m.expires_at IS NULL OR m.expires_at > ?)"
|
|
239
|
+
args.append(_iso(self._clock()))
|
|
240
|
+
sql += " ORDER BY score, m.created_at DESC LIMIT ?"
|
|
241
|
+
args.append(k)
|
|
242
|
+
with self._lock:
|
|
243
|
+
rows = self._db.execute(sql, args).fetchall()
|
|
244
|
+
return [SearchHit(_row_to_entry(r), -float(r["score"])) for r in rows]
|
|
245
|
+
|
|
246
|
+
def supersede(self, scope: Scope, old_id: str, text: str, *, kind: Optional[str] = None, source: str = "api",
|
|
247
|
+
trust: str = "untrusted", importance: Optional[float] = None,
|
|
248
|
+
expires_at: Optional[datetime] = None, metadata: Optional[Dict[str, Any]] = None) -> MemoryEntry:
|
|
249
|
+
"""Replace an entry whose content changed. The new entry gets the old entry's exact scope; the
|
|
250
|
+
old one stays in the store (see export, include_superseded) and points at its replacement."""
|
|
251
|
+
scope = require_scope(scope)
|
|
252
|
+
with self._tx() as db:
|
|
253
|
+
old = self._visible(db, scope, old_id)
|
|
254
|
+
if old["superseded_by"] is not None:
|
|
255
|
+
raise InvalidEntryError("entry is already superseded")
|
|
256
|
+
old_scope = Scope(old["tenant"], old["subject"], old["agent"], old["session"])
|
|
257
|
+
kind = kind or old["kind"]
|
|
258
|
+
importance = old["importance"] if importance is None else importance
|
|
259
|
+
_, exp, meta_json = self._validate_new(old_scope, text, kind, source, trust, importance, expires_at, metadata)
|
|
260
|
+
new = self._insert(db, old_scope, text, kind, source, trust, importance, exp, [old_id], meta_json)
|
|
261
|
+
db.execute("UPDATE memories SET superseded_by = ? WHERE id = ?", [new.id, old_id])
|
|
262
|
+
self._log("memory.supersede", scope, {"old": old_id, "new": new.id})
|
|
263
|
+
return new
|
|
264
|
+
|
|
265
|
+
def forget(self, scope: Scope, ids: Iterable[str]) -> int:
|
|
266
|
+
"""Delete the listed entries that `scope` can see; returns how many were deleted. They stop
|
|
267
|
+
appearing in searches at once, but traces can stay in the database file until compact()
|
|
268
|
+
or erase() runs."""
|
|
269
|
+
scope = require_scope(scope)
|
|
270
|
+
ids = list(ids)
|
|
271
|
+
if len(ids) > 1000 or not all(isinstance(i, str) and ID_RE.match(i) for i in ids):
|
|
272
|
+
raise InvalidEntryError("ids must be up to 1000 entry ids")
|
|
273
|
+
if not ids:
|
|
274
|
+
return 0
|
|
275
|
+
clause, params = _scope_clause(scope)
|
|
276
|
+
with self._tx() as db:
|
|
277
|
+
n = db.execute(f"DELETE FROM memories WHERE id IN ({','.join('?' * len(ids))}) AND {clause}",
|
|
278
|
+
[*ids, *params]).rowcount
|
|
279
|
+
self._log("memory.forget", scope, {"count": n})
|
|
280
|
+
return n
|
|
281
|
+
|
|
282
|
+
def export(self, scope: Scope) -> List[Dict[str, Any]]:
|
|
283
|
+
"""Every entry `scope` can see (superseded and expired ones too) as plain dicts."""
|
|
284
|
+
scope = require_scope(scope)
|
|
285
|
+
clause, params = _scope_clause(scope)
|
|
286
|
+
with self._tx() as db:
|
|
287
|
+
rows = db.execute(f"SELECT * FROM memories WHERE {clause} ORDER BY created_at, id", params).fetchall()
|
|
288
|
+
self._log("memory.export", scope, {"count": len(rows)})
|
|
289
|
+
return [_row_to_entry(r).to_dict() for r in rows]
|
|
290
|
+
|
|
291
|
+
def erase(self, scope: Scope) -> int:
|
|
292
|
+
"""Delete everything `scope` can see, then rewrite the search index and checkpoint the
|
|
293
|
+
journal so the deleted text is no longer in the database file. Returns the number deleted."""
|
|
294
|
+
scope = require_scope(scope)
|
|
295
|
+
clause, params = _scope_clause(scope)
|
|
296
|
+
with self._tx() as db:
|
|
297
|
+
n = db.execute(f"DELETE FROM memories WHERE {clause}", params).rowcount
|
|
298
|
+
self._log("memory.erase", scope, {"count": n})
|
|
299
|
+
self.compact()
|
|
300
|
+
return n
|
|
301
|
+
|
|
302
|
+
def sweep_expired(self, scope: Scope) -> int:
|
|
303
|
+
"""Delete expired entries inside `scope`; returns how many."""
|
|
304
|
+
scope = require_scope(scope)
|
|
305
|
+
clause, params = _scope_clause(scope)
|
|
306
|
+
with self._tx() as db:
|
|
307
|
+
n = db.execute(f"DELETE FROM memories WHERE {clause} AND expires_at IS NOT NULL AND expires_at <= ?",
|
|
308
|
+
[*params, _iso(self._clock())]).rowcount
|
|
309
|
+
self._log("memory.sweep", scope, {"count": n})
|
|
310
|
+
return n
|
|
311
|
+
|
|
312
|
+
def compact(self) -> None:
|
|
313
|
+
"""Rewrite the full-text index and checkpoint the journal. Needed to remove deleted text from
|
|
314
|
+
the file; erase() calls it. Can take a while on a large store."""
|
|
315
|
+
with self._lock:
|
|
316
|
+
self._db.execute("INSERT INTO memories_fts(memories_fts) VALUES ('optimize')")
|
|
317
|
+
if self.path != ":memory:":
|
|
318
|
+
self._db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
319
|
+
|
|
320
|
+
def close(self) -> None:
|
|
321
|
+
with self._lock:
|
|
322
|
+
self._db.close()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from ragleap_memory import InvalidEntryError, NotFoundError
|
|
6
|
+
from ragleap_security import LimitError, Scope, SecretStr
|
|
7
|
+
|
|
8
|
+
A = Scope("acme")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_add_returns_entry_with_safe_defaults(store):
|
|
12
|
+
e = store.add(A, "hello world")
|
|
13
|
+
assert e.scope == A and e.kind == "fact" and e.trust == "untrusted" and e.importance == 0.5
|
|
14
|
+
assert len(e.id) == 32 and e.superseded_by is None and e.derived_from == () and e.metadata == {}
|
|
15
|
+
assert store.get(A, e.id) == e
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_explicit_trust_kind_source_importance_metadata(store):
|
|
19
|
+
e = store.add(A, "x y", kind="summary", source="agent:planner", trust="owner", importance=1, metadata={"k": [1, 2]})
|
|
20
|
+
assert (e.kind, e.source, e.trust, e.importance, e.metadata) == ("summary", "agent:planner", "owner", 1, {"k": [1, 2]})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.parametrize("bad", ["", " ", None, 5, b"bytes"])
|
|
24
|
+
def test_bad_text_refused(store, bad):
|
|
25
|
+
with pytest.raises(InvalidEntryError):
|
|
26
|
+
store.add(A, bad)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_text_size_limit_counts_bytes_and_boundary(store):
|
|
30
|
+
store.add(A, "a" * 8192) # positive control: exactly at the limit
|
|
31
|
+
with pytest.raises(LimitError):
|
|
32
|
+
store.add(A, "a" * 8193)
|
|
33
|
+
with pytest.raises(LimitError):
|
|
34
|
+
store.add(A, "é" * 4097) # 8194 bytes
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.parametrize("field,value", [("kind", "note"), ("trust", "admin"), ("source", ""), ("source", "a b"),
|
|
38
|
+
("source", "-x"), ("importance", -0.1), ("importance", 1.1),
|
|
39
|
+
("importance", True), ("importance", "high"), ("importance", None)])
|
|
40
|
+
def test_bad_fields_refused(store, field, value):
|
|
41
|
+
with pytest.raises(InvalidEntryError):
|
|
42
|
+
store.add(A, "text", **{field: value})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.parametrize("scope", ["tenant=acme", None, {"tenant": "acme"}, 5])
|
|
46
|
+
def test_scope_must_be_a_scope(store, scope):
|
|
47
|
+
with pytest.raises(Exception) as e:
|
|
48
|
+
store.add(scope, "text")
|
|
49
|
+
assert type(e.value).__name__ == "ScopeError"
|
|
50
|
+
assert store.count(A) == 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_metadata_rules(store):
|
|
54
|
+
for bad in ([1], "x", 5):
|
|
55
|
+
with pytest.raises(InvalidEntryError):
|
|
56
|
+
store.add(A, "t", metadata=bad)
|
|
57
|
+
with pytest.raises(InvalidEntryError):
|
|
58
|
+
store.add(A, "t", metadata={"k": SecretStr("abcdefgh")}) # not JSON serializable
|
|
59
|
+
with pytest.raises(InvalidEntryError):
|
|
60
|
+
store.add(A, "t", metadata={"k": object()})
|
|
61
|
+
with pytest.raises(LimitError):
|
|
62
|
+
store.add(A, "t", metadata={"k": "x" * 5000})
|
|
63
|
+
deep = cur = {}
|
|
64
|
+
for _ in range(20):
|
|
65
|
+
cur["n"] = {}
|
|
66
|
+
cur = cur["n"]
|
|
67
|
+
with pytest.raises(LimitError):
|
|
68
|
+
store.add(A, "t", metadata=deep)
|
|
69
|
+
store.add(A, "t", metadata={"k": "x" * 4000}) # positive control under the size limit
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_expires_at_must_be_aware(store):
|
|
73
|
+
with pytest.raises(InvalidEntryError):
|
|
74
|
+
store.add(A, "t", expires_at=datetime.now())
|
|
75
|
+
with pytest.raises(InvalidEntryError):
|
|
76
|
+
store.add(A, "t", expires_at="2030-01-01")
|
|
77
|
+
e = store.add(A, "t", expires_at=datetime.now(timezone.utc) + timedelta(days=1))
|
|
78
|
+
assert e.expires_at.endswith("+00:00")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_derived_from_rules(store):
|
|
82
|
+
parent = store.add(A, "parent fact")
|
|
83
|
+
child = store.add(A, "summary of parent", kind="summary", derived_from=[parent.id])
|
|
84
|
+
assert child.derived_from == (parent.id,)
|
|
85
|
+
for bad in (["nothex"], [parent.id, parent.id], ["0" * 32], [5]):
|
|
86
|
+
with pytest.raises((InvalidEntryError, NotFoundError)):
|
|
87
|
+
store.add(A, "x", derived_from=bad)
|
|
88
|
+
with pytest.raises(InvalidEntryError):
|
|
89
|
+
store.add(A, "x", derived_from=["%032x" % i for i in range(101)])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_derived_from_cannot_point_into_another_tenant(store):
|
|
93
|
+
other = store.add(Scope("other"), "their secret")
|
|
94
|
+
with pytest.raises(NotFoundError):
|
|
95
|
+
store.add(A, "link to theirs", derived_from=[other.id])
|
|
96
|
+
assert store.count(A) == 0
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sqlite3
|
|
3
|
+
import stat
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from ragleap_memory import MemoryStoreError, NotFoundError, SQLiteStore
|
|
9
|
+
from ragleap_security import AuditError, AuditLog, Scope
|
|
10
|
+
|
|
11
|
+
A = Scope("acme")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_data_survives_reopen(tmp_path):
|
|
15
|
+
p = tmp_path / "mem.db"
|
|
16
|
+
s = SQLiteStore(p)
|
|
17
|
+
e = s.add(A, "remember this across restarts", trust="owner", metadata={"k": 1})
|
|
18
|
+
s.close()
|
|
19
|
+
s2 = SQLiteStore(p)
|
|
20
|
+
assert s2.get(A, e.id) == e
|
|
21
|
+
assert [h.entry.id for h in s2.search(A, "restarts")] == [e.id]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_database_file_is_private(tmp_path):
|
|
25
|
+
p = tmp_path / "mem.db"
|
|
26
|
+
SQLiteStore(p).add(A, "x y")
|
|
27
|
+
assert stat.S_IMODE(os.stat(p).st_mode) == 0o600
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_newer_schema_is_refused(tmp_path):
|
|
31
|
+
p = tmp_path / "mem.db"
|
|
32
|
+
SQLiteStore(p).close()
|
|
33
|
+
c = sqlite3.connect(p)
|
|
34
|
+
c.execute("PRAGMA user_version = 99")
|
|
35
|
+
c.commit()
|
|
36
|
+
c.close()
|
|
37
|
+
with pytest.raises(MemoryStoreError):
|
|
38
|
+
SQLiteStore(p)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_threads_can_add_concurrently(tmp_path):
|
|
42
|
+
s = SQLiteStore(tmp_path / "mem.db")
|
|
43
|
+
|
|
44
|
+
def work(n):
|
|
45
|
+
for i in range(25):
|
|
46
|
+
s.add(Scope("acme", f"u{n}"), f"note {n} {i}")
|
|
47
|
+
|
|
48
|
+
ts = [threading.Thread(target=work, args=(n,)) for n in range(8)]
|
|
49
|
+
[t.start() for t in ts]
|
|
50
|
+
[t.join() for t in ts]
|
|
51
|
+
assert s.count(A) == 200
|
|
52
|
+
for n in range(8):
|
|
53
|
+
assert s.count(Scope("acme", f"u{n}")) == 25
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_a_failed_write_leaves_nothing_behind(store):
|
|
57
|
+
parent = store.add(A, "parent")
|
|
58
|
+
with pytest.raises(NotFoundError):
|
|
59
|
+
store.add(A, "child", derived_from=[parent.id, "0" * 32]) # second id does not exist
|
|
60
|
+
assert store.count(A) == 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_audit_log_records_operations_without_text(tmp_path):
|
|
64
|
+
audit = AuditLog(tmp_path / "audit.jsonl")
|
|
65
|
+
s = SQLiteStore(audit=audit)
|
|
66
|
+
e = s.add(A, "top secret plan", trust="owner")
|
|
67
|
+
n = s.supersede(A, e.id, "revised secret plan", trust="owner")
|
|
68
|
+
s.export(A)
|
|
69
|
+
s.forget(A, [n.id])
|
|
70
|
+
s.sweep_expired(A)
|
|
71
|
+
s.erase(A)
|
|
72
|
+
raw = open(audit.path).read()
|
|
73
|
+
assert "secret plan" not in raw
|
|
74
|
+
events = [__import__("json").loads(l)["event"] for l in raw.splitlines()]
|
|
75
|
+
assert events == ["memory.add", "memory.supersede", "memory.export", "memory.forget", "memory.sweep", "memory.erase"]
|
|
76
|
+
assert audit.verify().ok and all(__import__("json").loads(l)["scope"] == "tenant=acme" for l in raw.splitlines())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_a_failing_audit_log_blocks_the_write(tmp_path):
|
|
80
|
+
class Broken:
|
|
81
|
+
def append(self, *a, **k):
|
|
82
|
+
raise AuditError("disk full")
|
|
83
|
+
|
|
84
|
+
s = SQLiteStore(audit=Broken())
|
|
85
|
+
with pytest.raises(AuditError):
|
|
86
|
+
s.add(A, "must not be stored unaudited")
|
|
87
|
+
assert s.count(A) == 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_a_failing_audit_log_blocks_erase_and_forget(tmp_path):
|
|
91
|
+
s = SQLiteStore()
|
|
92
|
+
e = s.add(A, "stays")
|
|
93
|
+
|
|
94
|
+
class Broken:
|
|
95
|
+
def append(self, *a, **k):
|
|
96
|
+
raise AuditError("disk full")
|
|
97
|
+
|
|
98
|
+
s._audit = Broken()
|
|
99
|
+
with pytest.raises(AuditError):
|
|
100
|
+
s.forget(A, [e.id])
|
|
101
|
+
with pytest.raises(AuditError):
|
|
102
|
+
s.erase(A)
|
|
103
|
+
assert s.count(A) == 1
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from ragleap_memory import NotFoundError
|
|
4
|
+
from ragleap_security import Scope
|
|
5
|
+
|
|
6
|
+
A, B = Scope("acme"), Scope("globex")
|
|
7
|
+
WORD = "quarterly forecast"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def two(store):
|
|
12
|
+
a = store.add(A, f"acme {WORD}")
|
|
13
|
+
b = store.add(B, f"globex {WORD}")
|
|
14
|
+
return store, a, b
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_search_never_crosses_tenants(two):
|
|
18
|
+
store, a, b = two
|
|
19
|
+
assert [h.entry.id for h in store.search(A, WORD)] == [a.id]
|
|
20
|
+
assert [h.entry.id for h in store.search(B, WORD)] == [b.id]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_get_across_tenants_looks_like_missing(two):
|
|
24
|
+
store, a, b = two
|
|
25
|
+
with pytest.raises(NotFoundError) as e1:
|
|
26
|
+
store.get(A, b.id)
|
|
27
|
+
with pytest.raises(NotFoundError) as e2:
|
|
28
|
+
store.get(A, "0" * 32)
|
|
29
|
+
assert str(e1.value) == str(e2.value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_count_export_are_per_tenant(two):
|
|
33
|
+
store, a, b = two
|
|
34
|
+
assert store.count(A) == 1 and store.count(B) == 1
|
|
35
|
+
assert [d["id"] for d in store.export(A)] == [a.id]
|
|
36
|
+
assert Scope("nobody").key and store.count(Scope("nobody")) == 0 and store.export(Scope("nobody")) == []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_forget_cannot_delete_another_tenants_entry(two):
|
|
40
|
+
store, a, b = two
|
|
41
|
+
assert store.forget(A, [b.id]) == 0
|
|
42
|
+
assert store.count(B) == 1
|
|
43
|
+
assert store.forget(A, [a.id, b.id]) == 1 # only its own
|
|
44
|
+
assert store.count(A) == 0 and store.count(B) == 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_supersede_cannot_touch_another_tenants_entry(two):
|
|
48
|
+
store, a, b = two
|
|
49
|
+
with pytest.raises(NotFoundError):
|
|
50
|
+
store.supersede(A, b.id, "overwritten")
|
|
51
|
+
assert store.get(B, b.id).superseded_by is None
|
|
52
|
+
assert store.count(B) == 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_erase_and_sweep_stay_inside_the_tenant(two):
|
|
56
|
+
store, a, b = two
|
|
57
|
+
assert store.erase(A) == 1
|
|
58
|
+
assert store.count(A) == 0 and store.count(B) == 1
|
|
59
|
+
assert store.sweep_expired(A) == 0
|
|
60
|
+
assert [h.entry.id for h in store.search(B, WORD)] == [b.id]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_narrow_scope_sees_only_its_own_subject(store):
|
|
64
|
+
alice, bob = Scope("acme", "alice"), Scope("acme", "bob")
|
|
65
|
+
ea = store.add(alice, f"alice {WORD}")
|
|
66
|
+
eb = store.add(bob, f"bob {WORD}")
|
|
67
|
+
assert [h.entry.id for h in store.search(alice, WORD)] == [ea.id]
|
|
68
|
+
assert [h.entry.id for h in store.search(bob, WORD)] == [eb.id]
|
|
69
|
+
with pytest.raises(NotFoundError):
|
|
70
|
+
store.get(alice, eb.id)
|
|
71
|
+
assert store.forget(alice, [eb.id]) == 0
|
|
72
|
+
assert store.erase(alice) == 1 and store.count(bob) == 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_tenant_wide_scope_sees_every_subject_in_its_tenant(store):
|
|
76
|
+
ea = store.add(Scope("acme", "alice"), f"alice {WORD}")
|
|
77
|
+
eb = store.add(Scope("acme", "bob"), f"bob {WORD}")
|
|
78
|
+
et = store.add(A, f"tenant-level {WORD}")
|
|
79
|
+
other = store.add(B, f"other {WORD}")
|
|
80
|
+
got = {h.entry.id for h in store.search(A, WORD, k=10)}
|
|
81
|
+
assert got == {ea.id, eb.id, et.id} and other.id not in got # positive control plus isolation
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_subject_scope_does_not_see_tenant_level_entries(store):
|
|
85
|
+
tenant_level = store.add(A, f"tenant-level {WORD}")
|
|
86
|
+
alice = Scope("acme", "alice")
|
|
87
|
+
assert store.search(alice, WORD) == []
|
|
88
|
+
with pytest.raises(NotFoundError):
|
|
89
|
+
store.get(alice, tenant_level.id)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_agent_and_session_parts_narrow_too(store):
|
|
93
|
+
s1 = Scope("acme", "alice", "bot", "s1")
|
|
94
|
+
s2 = Scope("acme", "alice", "bot", "s2")
|
|
95
|
+
e1 = store.add(s1, f"first {WORD}")
|
|
96
|
+
e2 = store.add(s2, f"second {WORD}")
|
|
97
|
+
assert [h.entry.id for h in store.search(s1, WORD)] == [e1.id]
|
|
98
|
+
assert {h.entry.id for h in store.search(Scope("acme", "alice", "bot"), WORD)} == {e1.id, e2.id}
|
|
99
|
+
assert store.search(Scope("acme", "alice", "other-bot"), WORD) == []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_search_query_cannot_escape_the_scope(two):
|
|
103
|
+
store, a, b = two
|
|
104
|
+
for q in ['forecast" OR tenant:globex', "tenant:globex forecast", "forecast NOT acme", "* forecast", 'a" OR "b',
|
|
105
|
+
"forecast AND (globex OR acme)", "NEAR(forecast quarterly)", "text: forecast"]:
|
|
106
|
+
ids = {h.entry.id for h in store.search(A, q, k=10)}
|
|
107
|
+
assert b.id not in ids, q
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from ragleap_memory import InvalidEntryError, NotFoundError, SQLiteStore
|
|
7
|
+
from ragleap_security import Scope
|
|
8
|
+
|
|
9
|
+
A = Scope("acme")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_supersede_links_old_and_new(store):
|
|
13
|
+
old = store.add(A, "owner phone is 111", kind="fact", trust="owner", importance=0.9)
|
|
14
|
+
new = store.supersede(A, old.id, "owner phone is 222", trust="owner")
|
|
15
|
+
assert new.derived_from == (old.id,) and new.kind == "fact" and new.importance == 0.9 and new.scope == A
|
|
16
|
+
assert store.get(A, old.id).superseded_by == new.id and store.get(A, new.id).superseded_by is None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_supersede_overrides_and_keeps_the_old_entrys_scope(store):
|
|
20
|
+
old = store.add(Scope("acme", "alice"), "likes tea")
|
|
21
|
+
new = store.supersede(A, old.id, "likes coffee", kind="summary", importance=0.2) # tenant-wide scope may do it
|
|
22
|
+
assert new.scope == Scope("acme", "alice") and new.kind == "summary" and new.importance == 0.2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_narrower_scope_cannot_supersede_a_wider_entry(store):
|
|
26
|
+
wide = store.add(A, "tenant-level fact")
|
|
27
|
+
with pytest.raises(NotFoundError):
|
|
28
|
+
store.supersede(Scope("acme", "alice"), wide.id, "changed")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_cannot_supersede_twice_or_with_bad_input(store):
|
|
32
|
+
old = store.add(A, "v1")
|
|
33
|
+
store.supersede(A, old.id, "v2")
|
|
34
|
+
with pytest.raises(InvalidEntryError):
|
|
35
|
+
store.supersede(A, old.id, "v3")
|
|
36
|
+
cur = store.add(A, "w1")
|
|
37
|
+
with pytest.raises(InvalidEntryError):
|
|
38
|
+
store.supersede(A, cur.id, "")
|
|
39
|
+
assert store.get(A, cur.id).superseded_by is None and store.count(A) == 3 # nothing half-written
|
|
40
|
+
with pytest.raises(NotFoundError):
|
|
41
|
+
store.supersede(A, "0" * 32, "x")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_forget_counts_and_removes_from_search(store):
|
|
45
|
+
a, b = store.add(A, "remove me"), store.add(A, "keep me")
|
|
46
|
+
assert store.forget(A, [a.id]) == 1 and store.forget(A, [a.id]) == 0 and store.forget(A, []) == 0
|
|
47
|
+
assert store.search(A, "remove") == [] and [h.entry.id for h in store.search(A, "keep")] == [b.id]
|
|
48
|
+
for bad in (["nothex"], [5], ["0" * 32] * 1001):
|
|
49
|
+
with pytest.raises(InvalidEntryError):
|
|
50
|
+
store.forget(A, bad)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_export_includes_everything_visible(store):
|
|
54
|
+
old = store.add(A, "old fact")
|
|
55
|
+
new = store.supersede(A, old.id, "new fact")
|
|
56
|
+
gone = store.add(A, "expired", expires_at=datetime(2000, 1, 1, tzinfo=timezone.utc))
|
|
57
|
+
ids = {d["id"] for d in store.export(A)}
|
|
58
|
+
assert ids == {old.id, new.id, gone.id}
|
|
59
|
+
d = next(x for x in store.export(A) if x["id"] == old.id)
|
|
60
|
+
assert d["scope"] == "tenant=acme" and d["superseded_by"] == new.id and d["text"] == "old fact"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_sweep_expired_deletes_only_expired_in_scope(store):
|
|
64
|
+
now = datetime.now(timezone.utc)
|
|
65
|
+
s = SQLiteStore(clock=lambda: now)
|
|
66
|
+
s.add(A, "stale", expires_at=now - timedelta(seconds=1))
|
|
67
|
+
live = s.add(A, "fresh", expires_at=now + timedelta(days=1))
|
|
68
|
+
forever = s.add(A, "forever")
|
|
69
|
+
other_stale = s.add(Scope("globex"), "other stale", expires_at=now - timedelta(days=1))
|
|
70
|
+
assert s.sweep_expired(A) == 1
|
|
71
|
+
assert {d["id"] for d in s.export(A)} == {live.id, forever.id}
|
|
72
|
+
assert s.count(Scope("globex")) == 1 and s.get(Scope("globex"), other_stale.id)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_erase_removes_text_from_the_file(tmp_path):
|
|
76
|
+
marker = "zyxwvmarkerqq"
|
|
77
|
+
path = tmp_path / "mem.db"
|
|
78
|
+
s = SQLiteStore(path)
|
|
79
|
+
for i in range(3):
|
|
80
|
+
s.add(A, f"keep this {i} and {marker} entry")
|
|
81
|
+
keep = s.add(Scope("globex"), "other tenant stays visible")
|
|
82
|
+
|
|
83
|
+
def raw():
|
|
84
|
+
return b"".join(open(str(path) + ext, "rb").read() for ext in ("", "-wal") if os.path.exists(str(path) + ext))
|
|
85
|
+
|
|
86
|
+
s.compact()
|
|
87
|
+
assert marker.encode() in raw() # positive control: the text really is in the file
|
|
88
|
+
assert s.erase(A) == 3
|
|
89
|
+
assert marker.encode() not in raw()
|
|
90
|
+
assert [h.entry.id for h in s.search(Scope("globex"), "tenant")] == [keep.id]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_forget_alone_may_leave_traces_until_compact(tmp_path):
|
|
94
|
+
# Documented limitation: forget() removes entries from queries at once, but the text can stay in
|
|
95
|
+
# the file's search index until compact() or erase() runs.
|
|
96
|
+
marker = "qwertymarkerzz"
|
|
97
|
+
path = tmp_path / "mem.db"
|
|
98
|
+
s = SQLiteStore(path)
|
|
99
|
+
e = s.add(A, f"entry with {marker}")
|
|
100
|
+
s.forget(A, [e.id])
|
|
101
|
+
s.compact()
|
|
102
|
+
raw = b"".join(open(str(path) + ext, "rb").read() for ext in ("", "-wal") if os.path.exists(str(path) + ext))
|
|
103
|
+
assert marker.encode() not in raw
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from ragleap_memory import InvalidEntryError
|
|
6
|
+
from ragleap_security import LimitError, Scope
|
|
7
|
+
|
|
8
|
+
A = Scope("acme")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_relevance_order_and_scores(store):
|
|
12
|
+
best = store.add(A, "invoice invoice invoice payment terms")
|
|
13
|
+
ok = store.add(A, "the invoice was sent on Monday with many other words around it")
|
|
14
|
+
store.add(A, "completely unrelated gardening note")
|
|
15
|
+
hits = store.search(A, "invoice")
|
|
16
|
+
assert [h.entry.id for h in hits] == [best.id, ok.id]
|
|
17
|
+
assert hits[0].score >= hits[1].score > 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_any_word_may_match_and_unmatched_returns_nothing(store):
|
|
21
|
+
e = store.add(A, "alpha beta gamma")
|
|
22
|
+
assert [h.entry.id for h in store.search(A, "zzz gamma qqq")] == [e.id]
|
|
23
|
+
assert store.search(A, "zzz qqq") == []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.parametrize("q", ["", " ", "!!! ???", '"""', "(", ")", "AND", "OR", "NOT", "*", "^", "-", ":"])
|
|
27
|
+
def test_odd_queries_do_not_error(store, q):
|
|
28
|
+
store.add(A, "alpha beta")
|
|
29
|
+
store.search(A, q)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_query_limits_and_types(store):
|
|
33
|
+
with pytest.raises(LimitError):
|
|
34
|
+
store.search(A, "x" * 1001)
|
|
35
|
+
with pytest.raises(InvalidEntryError):
|
|
36
|
+
store.search(A, 5)
|
|
37
|
+
store.search(A, "x " * 500) # positive control: exactly 1000 bytes, 32-token cap applies
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize("k", [0, -1, True, 1.5, "3", None])
|
|
41
|
+
def test_bad_k_refused(store, k):
|
|
42
|
+
with pytest.raises(InvalidEntryError):
|
|
43
|
+
store.search(A, "x", k=k)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_k_limits_results_and_is_capped(store):
|
|
47
|
+
for i in range(5):
|
|
48
|
+
store.add(A, f"common word {i}")
|
|
49
|
+
assert len(store.search(A, "common", k=2)) == 2
|
|
50
|
+
assert len(store.search(A, "common", k=10_000)) == 5 # under the store's cap of 50
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_store_wide_result_cap_applies_even_when_k_is_larger():
|
|
54
|
+
from ragleap_memory import SQLiteStore
|
|
55
|
+
s = SQLiteStore(max_results=3)
|
|
56
|
+
for i in range(6):
|
|
57
|
+
s.add(A, f"common word {i}")
|
|
58
|
+
assert len(s.search(A, "common", k=100)) == 3
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_kinds_filter(store):
|
|
62
|
+
f = store.add(A, "shared topic", kind="fact")
|
|
63
|
+
s = store.add(A, "shared topic", kind="summary")
|
|
64
|
+
assert {h.entry.id for h in store.search(A, "topic")} == {f.id, s.id}
|
|
65
|
+
assert [h.entry.id for h in store.search(A, "topic", kinds=["summary"])] == [s.id]
|
|
66
|
+
for bad in ([], ["note"], ["fact", "note"]):
|
|
67
|
+
with pytest.raises(InvalidEntryError):
|
|
68
|
+
store.search(A, "topic", kinds=bad)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_min_trust_filter(store):
|
|
72
|
+
u = store.add(A, "shared topic", trust="untrusted")
|
|
73
|
+
m = store.add(A, "shared topic", trust="model")
|
|
74
|
+
o = store.add(A, "shared topic", trust="owner")
|
|
75
|
+
assert {h.entry.id for h in store.search(A, "topic")} == {u.id, m.id, o.id}
|
|
76
|
+
assert {h.entry.id for h in store.search(A, "topic", min_trust="model")} == {m.id, o.id}
|
|
77
|
+
assert [h.entry.id for h in store.search(A, "topic", min_trust="owner")] == [o.id]
|
|
78
|
+
with pytest.raises(InvalidEntryError):
|
|
79
|
+
store.search(A, "topic", min_trust="root")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_search_results_carry_their_trust_label(store):
|
|
83
|
+
store.add(A, "labelled entry", trust="model")
|
|
84
|
+
assert store.search(A, "labelled")[0].entry.trust == "model"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_expired_entries_are_hidden_unless_asked(store):
|
|
88
|
+
now = [datetime(2030, 1, 1, tzinfo=timezone.utc)]
|
|
89
|
+
from ragleap_memory import SQLiteStore
|
|
90
|
+
s = SQLiteStore(clock=lambda: now[0])
|
|
91
|
+
e = s.add(A, "short lived note", expires_at=now[0] + timedelta(hours=1))
|
|
92
|
+
assert [h.entry.id for h in s.search(A, "note")] == [e.id]
|
|
93
|
+
now[0] += timedelta(hours=1) # exactly at the expiry instant: expired
|
|
94
|
+
assert s.search(A, "note") == []
|
|
95
|
+
assert [h.entry.id for h in s.search(A, "note", include_expired=True)] == [e.id]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_superseded_entries_are_hidden_unless_asked(store):
|
|
99
|
+
old = store.add(A, "price is ten")
|
|
100
|
+
new = store.supersede(A, old.id, "price is twelve")
|
|
101
|
+
assert [h.entry.id for h in store.search(A, "price")] == [new.id]
|
|
102
|
+
assert {h.entry.id for h in store.search(A, "price", include_superseded=True)} == {old.id, new.id}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.parametrize("text,query", [
|
|
106
|
+
("café au lait", "cafe"), ("Zürich Straße", "zurich"), ("नमस्ते दुनिया", "दुनिया"),
|
|
107
|
+
("வணக்கம் உலகம்", "உலகம்"), ("مرحبا بالعالم", "بالعالم"), ("你好 世界", "世界"),
|
|
108
|
+
])
|
|
109
|
+
def test_non_english_text_is_searchable(store, text, query):
|
|
110
|
+
e = store.add(A, text)
|
|
111
|
+
assert [h.entry.id for h in store.search(A, query)] == [e.id]
|