atomir 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.
- atomir-0.1.0/LICENSE +21 -0
- atomir-0.1.0/PKG-INFO +155 -0
- atomir-0.1.0/README.md +110 -0
- atomir-0.1.0/atomir/__init__.py +9 -0
- atomir-0.1.0/atomir/api.py +68 -0
- atomir-0.1.0/atomir/assembly.py +41 -0
- atomir-0.1.0/atomir/atomic_read.py +76 -0
- atomir-0.1.0/atomir/client.py +64 -0
- atomir-0.1.0/atomir/config.py +112 -0
- atomir-0.1.0/atomir/embeddings/__init__.py +11 -0
- atomir-0.1.0/atomir/embeddings/fake.py +48 -0
- atomir-0.1.0/atomir/embeddings/jina.py +86 -0
- atomir-0.1.0/atomir/extractor.py +34 -0
- atomir-0.1.0/atomir/llm/__init__.py +11 -0
- atomir-0.1.0/atomir/llm/fake.py +46 -0
- atomir-0.1.0/atomir/llm/groq.py +85 -0
- atomir-0.1.0/atomir/llm/parsing.py +64 -0
- atomir-0.1.0/atomir/locking.py +26 -0
- atomir-0.1.0/atomir/memory.py +77 -0
- atomir-0.1.0/atomir/providers/__init__.py +11 -0
- atomir-0.1.0/atomir/providers/embedder_base.py +27 -0
- atomir-0.1.0/atomir/providers/factory.py +57 -0
- atomir-0.1.0/atomir/providers/llm_base.py +20 -0
- atomir-0.1.0/atomir/reconciler.py +92 -0
- atomir-0.1.0/atomir/store_base.py +79 -0
- atomir-0.1.0/atomir/stores/__init__.py +9 -0
- atomir-0.1.0/atomir/stores/json_store.py +143 -0
- atomir-0.1.0/atomir/stores/qdrant_store.py +151 -0
- atomir-0.1.0/atomir.egg-info/PKG-INFO +155 -0
- atomir-0.1.0/atomir.egg-info/SOURCES.txt +33 -0
- atomir-0.1.0/atomir.egg-info/dependency_links.txt +1 -0
- atomir-0.1.0/atomir.egg-info/requires.txt +18 -0
- atomir-0.1.0/atomir.egg-info/top_level.txt +1 -0
- atomir-0.1.0/pyproject.toml +31 -0
- atomir-0.1.0/setup.cfg +4 -0
atomir-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bereket Tilahun Shimekit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
atomir-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: atomir
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Atomic memory infrastructure for agents — atomic facts on write, atomic sub-question decomposition on read.
|
|
5
|
+
Author: bekiTil
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Bereket Tilahun Shimekit
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: python-dotenv>=1.0
|
|
32
|
+
Requires-Dist: numpy>=1.26
|
|
33
|
+
Provides-Extra: groq
|
|
34
|
+
Provides-Extra: jina
|
|
35
|
+
Provides-Extra: qdrant
|
|
36
|
+
Requires-Dist: qdrant-client>=1.7; extra == "qdrant"
|
|
37
|
+
Provides-Extra: api
|
|
38
|
+
Requires-Dist: fastapi>=0.100; extra == "api"
|
|
39
|
+
Requires-Dist: uvicorn[standard]>=0.23; extra == "api"
|
|
40
|
+
Provides-Extra: all
|
|
41
|
+
Requires-Dist: qdrant-client>=1.7; extra == "all"
|
|
42
|
+
Requires-Dist: fastapi>=0.100; extra == "all"
|
|
43
|
+
Requires-Dist: uvicorn[standard]>=0.23; extra == "all"
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
|
|
46
|
+
# atomir
|
|
47
|
+
|
|
48
|
+
Atomic memory infrastructure for agents. **Memory is atomic on both ends:**
|
|
49
|
+
atomic facts on write (extract → reconcile), atomic sub-question decomposition on
|
|
50
|
+
read (decompose → retrieve per sub-question → union).
|
|
51
|
+
|
|
52
|
+
## The thesis
|
|
53
|
+
|
|
54
|
+
Most memory systems store raw text blobs and retrieve with a single fuzzy
|
|
55
|
+
similarity search. atomir does the opposite at both ends:
|
|
56
|
+
|
|
57
|
+
- **Write** — a message is split into small, self-contained facts, and each is
|
|
58
|
+
*reconciled* into memory (ADD new, UPDATE a changed value keeping history,
|
|
59
|
+
DELETE what's no longer true, NOOP duplicates). A similarity gate biases toward
|
|
60
|
+
ADD so distinct facts never over-merge.
|
|
61
|
+
- **Read** — a question is decomposed into atomic sub-questions (only when it
|
|
62
|
+
helps), each retrieved independently, then results are unioned. This surfaces
|
|
63
|
+
facts a single whole-question embedding misses.
|
|
64
|
+
|
|
65
|
+
## Vendor-neutral by construction
|
|
66
|
+
|
|
67
|
+
The LLM, the embedder, and the vector store are each an **interface** chosen at
|
|
68
|
+
runtime by config (`{provider, config}` blocks). The engine imports only the
|
|
69
|
+
interfaces — never a provider SDK or vendor name. Swapping Groq↔OpenAI,
|
|
70
|
+
Jina↔Voyage, or Qdrant↔pgvector is one config change plus one small class.
|
|
71
|
+
Defaults use `fake` backends, so everything runs with **no external keys**.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install -e . # core (offline: fake LLM + fake embedder + JSON store)
|
|
77
|
+
pip install -e ".[qdrant]" # add the Qdrant backend
|
|
78
|
+
pip install -e ".[api]" # add the FastAPI server
|
|
79
|
+
pip install -e ".[all]" # everything
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`groq` and `jina` need no extra — they call their HTTP APIs over the standard
|
|
83
|
+
library.
|
|
84
|
+
|
|
85
|
+
## Quickstart — embedded, no Docker
|
|
86
|
+
|
|
87
|
+
Runs fully offline with the default `fake` backends:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from atomir.assembly import build_memory_service
|
|
91
|
+
|
|
92
|
+
mem = build_memory_service() # backends chosen by .env
|
|
93
|
+
mem.add("user123", "I'm vegetarian and my manager is Dana Lopez.")
|
|
94
|
+
mem.add("user123", "I'm working on Project Atlas.")
|
|
95
|
+
|
|
96
|
+
hits = mem.search("user123", "who should I email about my project?")
|
|
97
|
+
print(hits["subquestions"]) # the sub-questions it asked
|
|
98
|
+
for r in hits["results"]:
|
|
99
|
+
print(r["text"], round(r["score"], 3))
|
|
100
|
+
|
|
101
|
+
mem.get_all("user123")
|
|
102
|
+
mem.delete("user123", fact_id)
|
|
103
|
+
mem.reset("user123")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
To use real providers, copy `.env.example` to `.env` and set the keys/backends.
|
|
107
|
+
|
|
108
|
+
## Production — Docker Compose (API + Qdrant server)
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
cp .env.example .env # optional: add real keys; without it, LLM/embedder run fake
|
|
112
|
+
docker compose up --build # brings up the API and a Qdrant server
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The API points at the Qdrant service via `STORE_URL=http://qdrant:6333`. Then:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
curl -XPOST localhost:8000/memories -H 'content-type: application/json' \
|
|
119
|
+
-d '{"user_id":"u1","text":"My manager is Dana."}'
|
|
120
|
+
curl -XPOST localhost:8000/search -H 'content-type: application/json' \
|
|
121
|
+
-d '{"user_id":"u1","query":"who is my manager?"}'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## HTTP endpoints
|
|
125
|
+
|
|
126
|
+
| Method | Path | Body / query | Returns |
|
|
127
|
+
|---|---|---|---|
|
|
128
|
+
| POST | `/memories` | `{user_id, text}` | `{operations, facts}` |
|
|
129
|
+
| POST | `/search` | `{user_id, query, k?, decompose?}` | `{subquestions, results}` |
|
|
130
|
+
| GET | `/memories` | `?user_id=` | list of facts |
|
|
131
|
+
| DELETE | `/memories/{id}` | `?user_id=` | `{deleted, id}` (404 if absent) |
|
|
132
|
+
| DELETE | `/memories` | `?user_id=` | `{reset}` |
|
|
133
|
+
| GET | `/health` | — | `{status, store, llm, embedder}` |
|
|
134
|
+
|
|
135
|
+
`MemoryClient(base_url)` (in `atomir.client`) wraps these with the same method
|
|
136
|
+
names and return shapes.
|
|
137
|
+
|
|
138
|
+
## Configuration
|
|
139
|
+
|
|
140
|
+
All config is read from the environment (see `.env.example`): `LLM_BACKEND`,
|
|
141
|
+
`LLM_API_KEY`, `MODEL`, `EMBED_BACKEND`, `EMBED_API_KEY`, `EMBED_DIM`,
|
|
142
|
+
`RECONCILE_MIN_SIM`, `STORE_BACKEND`, `COLLECTION`, `STORE_URL`, `STORE_PATH`.
|
|
143
|
+
|
|
144
|
+
## Known limitations
|
|
145
|
+
|
|
146
|
+
- **Reconciler threshold is untuned.** `RECONCILE_MIN_SIM` defaults to `0.6`; on
|
|
147
|
+
Jina, real "same-attribute" pairs measured ~0.6, so it sits right on the edge.
|
|
148
|
+
It should be tuned per embedder with the eval harness, not trusted as-is.
|
|
149
|
+
- **The JSON backend is NOT crash-safe.** It rewrites the whole file without
|
|
150
|
+
atomic replace/fsync — dev and tests only. Use Qdrant for durable storage.
|
|
151
|
+
- **No transactions.** Writes are serialized per user with a simple lock
|
|
152
|
+
(Step 9); full transactional rollback is deferred (DECISION #5).
|
|
153
|
+
- **Read returns facts, not a composed answer.** `search` returns the relevant
|
|
154
|
+
facts and sub-questions; turning them into a final sentence is the caller's
|
|
155
|
+
LLM's job.
|
atomir-0.1.0/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# atomir
|
|
2
|
+
|
|
3
|
+
Atomic memory infrastructure for agents. **Memory is atomic on both ends:**
|
|
4
|
+
atomic facts on write (extract → reconcile), atomic sub-question decomposition on
|
|
5
|
+
read (decompose → retrieve per sub-question → union).
|
|
6
|
+
|
|
7
|
+
## The thesis
|
|
8
|
+
|
|
9
|
+
Most memory systems store raw text blobs and retrieve with a single fuzzy
|
|
10
|
+
similarity search. atomir does the opposite at both ends:
|
|
11
|
+
|
|
12
|
+
- **Write** — a message is split into small, self-contained facts, and each is
|
|
13
|
+
*reconciled* into memory (ADD new, UPDATE a changed value keeping history,
|
|
14
|
+
DELETE what's no longer true, NOOP duplicates). A similarity gate biases toward
|
|
15
|
+
ADD so distinct facts never over-merge.
|
|
16
|
+
- **Read** — a question is decomposed into atomic sub-questions (only when it
|
|
17
|
+
helps), each retrieved independently, then results are unioned. This surfaces
|
|
18
|
+
facts a single whole-question embedding misses.
|
|
19
|
+
|
|
20
|
+
## Vendor-neutral by construction
|
|
21
|
+
|
|
22
|
+
The LLM, the embedder, and the vector store are each an **interface** chosen at
|
|
23
|
+
runtime by config (`{provider, config}` blocks). The engine imports only the
|
|
24
|
+
interfaces — never a provider SDK or vendor name. Swapping Groq↔OpenAI,
|
|
25
|
+
Jina↔Voyage, or Qdrant↔pgvector is one config change plus one small class.
|
|
26
|
+
Defaults use `fake` backends, so everything runs with **no external keys**.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e . # core (offline: fake LLM + fake embedder + JSON store)
|
|
32
|
+
pip install -e ".[qdrant]" # add the Qdrant backend
|
|
33
|
+
pip install -e ".[api]" # add the FastAPI server
|
|
34
|
+
pip install -e ".[all]" # everything
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`groq` and `jina` need no extra — they call their HTTP APIs over the standard
|
|
38
|
+
library.
|
|
39
|
+
|
|
40
|
+
## Quickstart — embedded, no Docker
|
|
41
|
+
|
|
42
|
+
Runs fully offline with the default `fake` backends:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from atomir.assembly import build_memory_service
|
|
46
|
+
|
|
47
|
+
mem = build_memory_service() # backends chosen by .env
|
|
48
|
+
mem.add("user123", "I'm vegetarian and my manager is Dana Lopez.")
|
|
49
|
+
mem.add("user123", "I'm working on Project Atlas.")
|
|
50
|
+
|
|
51
|
+
hits = mem.search("user123", "who should I email about my project?")
|
|
52
|
+
print(hits["subquestions"]) # the sub-questions it asked
|
|
53
|
+
for r in hits["results"]:
|
|
54
|
+
print(r["text"], round(r["score"], 3))
|
|
55
|
+
|
|
56
|
+
mem.get_all("user123")
|
|
57
|
+
mem.delete("user123", fact_id)
|
|
58
|
+
mem.reset("user123")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
To use real providers, copy `.env.example` to `.env` and set the keys/backends.
|
|
62
|
+
|
|
63
|
+
## Production — Docker Compose (API + Qdrant server)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
cp .env.example .env # optional: add real keys; without it, LLM/embedder run fake
|
|
67
|
+
docker compose up --build # brings up the API and a Qdrant server
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The API points at the Qdrant service via `STORE_URL=http://qdrant:6333`. Then:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
curl -XPOST localhost:8000/memories -H 'content-type: application/json' \
|
|
74
|
+
-d '{"user_id":"u1","text":"My manager is Dana."}'
|
|
75
|
+
curl -XPOST localhost:8000/search -H 'content-type: application/json' \
|
|
76
|
+
-d '{"user_id":"u1","query":"who is my manager?"}'
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## HTTP endpoints
|
|
80
|
+
|
|
81
|
+
| Method | Path | Body / query | Returns |
|
|
82
|
+
|---|---|---|---|
|
|
83
|
+
| POST | `/memories` | `{user_id, text}` | `{operations, facts}` |
|
|
84
|
+
| POST | `/search` | `{user_id, query, k?, decompose?}` | `{subquestions, results}` |
|
|
85
|
+
| GET | `/memories` | `?user_id=` | list of facts |
|
|
86
|
+
| DELETE | `/memories/{id}` | `?user_id=` | `{deleted, id}` (404 if absent) |
|
|
87
|
+
| DELETE | `/memories` | `?user_id=` | `{reset}` |
|
|
88
|
+
| GET | `/health` | — | `{status, store, llm, embedder}` |
|
|
89
|
+
|
|
90
|
+
`MemoryClient(base_url)` (in `atomir.client`) wraps these with the same method
|
|
91
|
+
names and return shapes.
|
|
92
|
+
|
|
93
|
+
## Configuration
|
|
94
|
+
|
|
95
|
+
All config is read from the environment (see `.env.example`): `LLM_BACKEND`,
|
|
96
|
+
`LLM_API_KEY`, `MODEL`, `EMBED_BACKEND`, `EMBED_API_KEY`, `EMBED_DIM`,
|
|
97
|
+
`RECONCILE_MIN_SIM`, `STORE_BACKEND`, `COLLECTION`, `STORE_URL`, `STORE_PATH`.
|
|
98
|
+
|
|
99
|
+
## Known limitations
|
|
100
|
+
|
|
101
|
+
- **Reconciler threshold is untuned.** `RECONCILE_MIN_SIM` defaults to `0.6`; on
|
|
102
|
+
Jina, real "same-attribute" pairs measured ~0.6, so it sits right on the edge.
|
|
103
|
+
It should be tuned per embedder with the eval harness, not trusted as-is.
|
|
104
|
+
- **The JSON backend is NOT crash-safe.** It rewrites the whole file without
|
|
105
|
+
atomic replace/fsync — dev and tests only. Use Qdrant for durable storage.
|
|
106
|
+
- **No transactions.** Writes are serialized per user with a simple lock
|
|
107
|
+
(Step 9); full transactional rollback is deferred (DECISION #5).
|
|
108
|
+
- **Read returns facts, not a composed answer.** `search` returns the relevant
|
|
109
|
+
facts and sub-questions; turning them into a final sentence is the caller's
|
|
110
|
+
LLM's job.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""atomir — atomic memory infrastructure for agents.
|
|
2
|
+
|
|
3
|
+
Memory is atomic on both ends: atomic facts on write, atomic sub-question
|
|
4
|
+
decomposition on read. This package is built inside-out (engine → storage →
|
|
5
|
+
multi-tenancy → API → packaging) and is vendor-neutral: the LLM, embedder, and
|
|
6
|
+
vector store are each an interface chosen at runtime by config.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""HTTP surface over the engine (FastAPI). A thin adapter, no business logic.
|
|
2
|
+
|
|
3
|
+
Endpoints unpack a request, call ONE `MemoryService` method, and return its
|
|
4
|
+
result. Handlers are plain `def` (not `async def`) so Starlette runs the
|
|
5
|
+
blocking engine work in a threadpool instead of stalling the event loop; the
|
|
6
|
+
per-user lock inside `MemoryService` keeps same-user writes correct across those
|
|
7
|
+
threads.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from atomir.assembly import build_memory_service
|
|
16
|
+
from atomir.config import settings
|
|
17
|
+
|
|
18
|
+
service = build_memory_service()
|
|
19
|
+
app = FastAPI(title="atomir", version="0.1.0")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AddBody(BaseModel):
|
|
23
|
+
user_id: str
|
|
24
|
+
text: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SearchBody(BaseModel):
|
|
28
|
+
user_id: str
|
|
29
|
+
query: str
|
|
30
|
+
k: int = 6
|
|
31
|
+
decompose: bool = True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.post("/memories")
|
|
35
|
+
def add_memories(body: AddBody) -> dict:
|
|
36
|
+
return service.add(body.user_id, body.text)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.post("/search")
|
|
40
|
+
def search(body: SearchBody) -> dict:
|
|
41
|
+
return service.search(body.user_id, body.query, k=body.k, decompose=body.decompose)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.get("/memories")
|
|
45
|
+
def get_all(user_id: str = Query(...)) -> list[dict]:
|
|
46
|
+
return service.get_all(user_id)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.delete("/memories/{fact_id}")
|
|
50
|
+
def delete_one(fact_id: str, user_id: str = Query(...)) -> dict:
|
|
51
|
+
if not service.delete(user_id, fact_id):
|
|
52
|
+
raise HTTPException(status_code=404, detail="fact not found for this user")
|
|
53
|
+
return {"deleted": True, "id": fact_id}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.delete("/memories")
|
|
57
|
+
def reset(user_id: str = Query(...)) -> dict:
|
|
58
|
+
return {"reset": service.reset(user_id)}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.get("/health")
|
|
62
|
+
def health() -> dict:
|
|
63
|
+
return {
|
|
64
|
+
"status": "ok",
|
|
65
|
+
"store": settings.store_backend,
|
|
66
|
+
"llm": settings.llm_backend,
|
|
67
|
+
"embedder": settings.embed_backend,
|
|
68
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Assembly: build a `MemoryService` from config.
|
|
2
|
+
|
|
3
|
+
This is the OUTERMOST wiring layer — the one place allowed to name concrete
|
|
4
|
+
backends. It selects the LLM and embedder through their factories and the store
|
|
5
|
+
through a small backend switch, then injects all three into the (vendor-neutral)
|
|
6
|
+
`MemoryService`. Concrete backends are imported lazily so building a fake/JSON
|
|
7
|
+
service never requires an unused provider SDK.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from atomir.config import Settings
|
|
13
|
+
from atomir.config import make_qdrant_client
|
|
14
|
+
from atomir.config import settings as default_settings
|
|
15
|
+
from atomir.memory import MemoryService
|
|
16
|
+
from atomir.providers import EmbedderFactory, LLMFactory
|
|
17
|
+
from atomir.store_base import MemoryStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _build_store(s: Settings) -> MemoryStore:
|
|
21
|
+
provider = s.store_backend
|
|
22
|
+
if provider == "qdrant":
|
|
23
|
+
from atomir.stores.qdrant_store import QdrantMemoryStore
|
|
24
|
+
|
|
25
|
+
return QdrantMemoryStore(make_qdrant_client(), s.collection, s.embed_dim)
|
|
26
|
+
if provider == "json":
|
|
27
|
+
from atomir.stores.json_store import JsonMemoryStore
|
|
28
|
+
|
|
29
|
+
path = s.store_path if s.store_path.endswith(".json") else "./atomir_store.json"
|
|
30
|
+
return JsonMemoryStore(path=path)
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Unknown store backend {provider!r}. Valid backends: ['qdrant', 'json']"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_memory_service(s: Settings = default_settings) -> MemoryService:
|
|
37
|
+
"""Construct a MemoryService with providers/backend selected purely by config."""
|
|
38
|
+
llm = LLMFactory.create(s.llm)
|
|
39
|
+
embedder = EmbedderFactory.create(s.embedder)
|
|
40
|
+
store = _build_store(s)
|
|
41
|
+
return MemoryService(store, llm, embedder, reconcile_min_sim=s.reconcile_min_sim)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Atomic READ: answer a question by querying memory atomically.
|
|
2
|
+
|
|
3
|
+
The read-side twin of the write engine. Instead of embedding a whole question
|
|
4
|
+
as one blob, a planner may decompose it into atomic sub-questions; each is
|
|
5
|
+
retrieved independently and the results are unioned (deduped by fact id, best
|
|
6
|
+
score kept). Vendor-neutral: imports ONLY the injected interfaces.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from atomir.providers.embedder_base import Embedder
|
|
12
|
+
from atomir.providers.llm_base import LLM
|
|
13
|
+
from atomir.store_base import MemoryStore
|
|
14
|
+
|
|
15
|
+
_PLAN_SYSTEM = (
|
|
16
|
+
"You are a query planner for a personal-memory system. Decide whether a "
|
|
17
|
+
"question must be broken into atomic sub-questions to answer it.\n"
|
|
18
|
+
"- SIMPLE single-fact question -> decompose=false and subquestions=[the "
|
|
19
|
+
"original question verbatim].\n"
|
|
20
|
+
"- MULTI-HOP question (the answer depends on an intermediate fact, e.g. a "
|
|
21
|
+
"person, project, or relationship you must resolve first) -> decompose=true "
|
|
22
|
+
"with atomic wh- sub-questions, INCLUDING the intermediate facts.\n\n"
|
|
23
|
+
"Examples:\n"
|
|
24
|
+
'Q: "where does the user live?"\n'
|
|
25
|
+
'{"decompose": false, "subquestions": ["where does the user live?"]}\n'
|
|
26
|
+
'Q: "what gift should I buy my sister?"\n'
|
|
27
|
+
'{"decompose": true, "subquestions": ["who is the user\'s sister?", '
|
|
28
|
+
'"what does the user\'s sister like?"]}\n\n'
|
|
29
|
+
"Respond ONLY with JSON: "
|
|
30
|
+
'{"decompose": <true|false>, "subquestions": ["...", "..."]}'
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def plan(llm: LLM, query: str) -> dict:
|
|
35
|
+
"""Decide whether/how to decompose `query`. Always returns >=1 subquestion."""
|
|
36
|
+
result = llm.chat_json(_PLAN_SYSTEM, query)
|
|
37
|
+
decompose = bool(result.get("decompose", False))
|
|
38
|
+
subs = [
|
|
39
|
+
s.strip()
|
|
40
|
+
for s in (result.get("subquestions") or [])
|
|
41
|
+
if isinstance(s, str) and s.strip()
|
|
42
|
+
]
|
|
43
|
+
if not subs: # planner gave nothing usable -> fall back to the raw query
|
|
44
|
+
subs, decompose = [query], False
|
|
45
|
+
return {"decompose": decompose, "subquestions": subs}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def atomic_search(
|
|
49
|
+
store: MemoryStore,
|
|
50
|
+
llm: LLM,
|
|
51
|
+
embedder: Embedder,
|
|
52
|
+
user_id: str,
|
|
53
|
+
query: str,
|
|
54
|
+
k: int = 6,
|
|
55
|
+
decompose: bool = True,
|
|
56
|
+
) -> dict:
|
|
57
|
+
"""Retrieve facts for `query`, decomposing into sub-questions when useful.
|
|
58
|
+
|
|
59
|
+
Returns {"subquestions": [...], "results": [...]} — sub-questions are
|
|
60
|
+
exposed deliberately (the differentiator, and a debugging aid).
|
|
61
|
+
"""
|
|
62
|
+
subquestions = plan(llm, query)["subquestions"] if decompose else [query]
|
|
63
|
+
|
|
64
|
+
# Each sub-question is embedded as a QUERY (here the input really is a
|
|
65
|
+
# question) and retrieved independently. These retrievals are mutually
|
|
66
|
+
# independent -> safe to run concurrently (deferred to Step 9); sequential
|
|
67
|
+
# here for determinism.
|
|
68
|
+
best_by_id: dict[str, dict] = {}
|
|
69
|
+
for sq in subquestions:
|
|
70
|
+
for hit in store.search(user_id, embedder.embed_query(sq), k=k):
|
|
71
|
+
hid = hit["id"]
|
|
72
|
+
if hid not in best_by_id or hit["score"] > best_by_id[hid]["score"]:
|
|
73
|
+
best_by_id[hid] = hit
|
|
74
|
+
|
|
75
|
+
results = sorted(best_by_id.values(), key=lambda h: h["score"], reverse=True)[:k]
|
|
76
|
+
return {"subquestions": subquestions, "results": results}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Client SDK: adoption in one import.
|
|
2
|
+
|
|
3
|
+
`MemoryClient` mirrors the HTTP API (and the `MemoryService` facade) method for
|
|
4
|
+
method, with IDENTICAL return shapes — so callers can move between the library,
|
|
5
|
+
the service, and this SDK without changing code. Built on stdlib urllib, so the
|
|
6
|
+
SDK adds no dependency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from urllib.parse import urlencode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MemoryClient:
|
|
18
|
+
"""Thin REST wrapper around an atomir API server."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, base_url: str = "http://localhost:8000", timeout: float = 30.0) -> None:
|
|
21
|
+
self.base_url = base_url.rstrip("/")
|
|
22
|
+
self.timeout = timeout
|
|
23
|
+
|
|
24
|
+
def _request(self, method: str, path: str, *, body: dict | None = None, params: dict | None = None):
|
|
25
|
+
url = self.base_url + path
|
|
26
|
+
if params:
|
|
27
|
+
url += "?" + urlencode(params)
|
|
28
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
29
|
+
req = urllib.request.Request(
|
|
30
|
+
url,
|
|
31
|
+
data=data,
|
|
32
|
+
method=method,
|
|
33
|
+
headers={"Content-Type": "application/json"} if data is not None else {},
|
|
34
|
+
)
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
37
|
+
raw = resp.read().decode("utf-8")
|
|
38
|
+
return json.loads(raw) if raw else None
|
|
39
|
+
except urllib.error.HTTPError as e:
|
|
40
|
+
detail = e.read().decode("utf-8", "replace")[:300]
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
f"atomir API {method} {path} failed: {e.code} {e.reason} — {detail}"
|
|
43
|
+
) from e
|
|
44
|
+
|
|
45
|
+
def add(self, user_id: str, text: str) -> dict:
|
|
46
|
+
return self._request("POST", "/memories", body={"user_id": user_id, "text": text})
|
|
47
|
+
|
|
48
|
+
def search(self, user_id: str, query: str, k: int = 6, decompose: bool = True) -> dict:
|
|
49
|
+
return self._request(
|
|
50
|
+
"POST", "/search",
|
|
51
|
+
body={"user_id": user_id, "query": query, "k": k, "decompose": decompose},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def get_all(self, user_id: str) -> list[dict]:
|
|
55
|
+
return self._request("GET", "/memories", params={"user_id": user_id})
|
|
56
|
+
|
|
57
|
+
def delete(self, user_id: str, fact_id: str) -> dict:
|
|
58
|
+
return self._request("DELETE", f"/memories/{fact_id}", params={"user_id": user_id})
|
|
59
|
+
|
|
60
|
+
def reset(self, user_id: str) -> dict:
|
|
61
|
+
return self._request("DELETE", "/memories", params={"user_id": user_id})
|
|
62
|
+
|
|
63
|
+
def health(self) -> dict:
|
|
64
|
+
return self._request("GET", "/health")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Centralized, vendor-neutral configuration.
|
|
2
|
+
|
|
3
|
+
12-factor (factor III): all config that varies between deploys — keys, model
|
|
4
|
+
names, URLs, paths — is read from the environment, never hardcoded. A local
|
|
5
|
+
`.env` file is loaded for convenience in development.
|
|
6
|
+
|
|
7
|
+
The design exposes THREE independent, swappable provider slots — `llm`,
|
|
8
|
+
`embedder`, and `vector_store` — each as a mem0-style `{provider, config}`
|
|
9
|
+
block. No vendor is privileged: whichever providers the environment selects are
|
|
10
|
+
the ones used. The first-run example happens to be Groq + Jina + Qdrant, but
|
|
11
|
+
each is just one value of an interface that gets formalized in later steps.
|
|
12
|
+
|
|
13
|
+
Defaults favor running with NO external keys: both the LLM and embedder default
|
|
14
|
+
to the `fake` backend so the system is testable offline.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from dotenv import load_dotenv
|
|
23
|
+
|
|
24
|
+
# Load .env into os.environ if present (no error if the file is missing).
|
|
25
|
+
load_dotenv()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _env(name: str, default: str = "") -> str:
|
|
29
|
+
return os.environ.get(name, default)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Settings:
|
|
34
|
+
"""Immutable snapshot of configuration read from the environment."""
|
|
35
|
+
|
|
36
|
+
# --- LLM slot ---------------------------------------------------------
|
|
37
|
+
# Field names stay vendor-neutral: `llm_backend` selects the impl, and the
|
|
38
|
+
# key/model are read into generic fields. `groq` is just the first example.
|
|
39
|
+
llm_backend: str = field(default_factory=lambda: _env("LLM_BACKEND", "fake"))
|
|
40
|
+
llm_api_key: str = field(default_factory=lambda: _env("LLM_API_KEY"))
|
|
41
|
+
model: str = field(default_factory=lambda: _env("MODEL", "llama-3.3-70b-versatile"))
|
|
42
|
+
|
|
43
|
+
# --- Embedder slot ----------------------------------------------------
|
|
44
|
+
# Same neutrality: `jina` is the first example, but nothing here names it.
|
|
45
|
+
embed_backend: str = field(default_factory=lambda: _env("EMBED_BACKEND", "fake"))
|
|
46
|
+
embed_api_key: str = field(default_factory=lambda: _env("EMBED_API_KEY"))
|
|
47
|
+
# Dimension travels WITH the embedder choice (Jina v3 = 1024).
|
|
48
|
+
embed_dim: int = field(default_factory=lambda: int(_env("EMBED_DIM", "1024")))
|
|
49
|
+
|
|
50
|
+
# --- Reconciler tuning ------------------------------------------------
|
|
51
|
+
# Write-side similarity gate (DECISION #1a): a candidate whose nearest
|
|
52
|
+
# existing fact scores below this is ADDed directly, without asking the LLM.
|
|
53
|
+
reconcile_min_sim: float = field(
|
|
54
|
+
default_factory=lambda: float(_env("RECONCILE_MIN_SIM", "0.6"))
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# --- Vector store slot ------------------------------------------------
|
|
58
|
+
# Selected by `store_backend` like the other two slots (no hardcoded vendor).
|
|
59
|
+
store_backend: str = field(default_factory=lambda: _env("STORE_BACKEND", "qdrant"))
|
|
60
|
+
collection: str = field(default_factory=lambda: _env("COLLECTION", "atomir_memories"))
|
|
61
|
+
# `url`/`path` are generic connection params (Qdrant uses both today).
|
|
62
|
+
store_url: str = field(default_factory=lambda: _env("STORE_URL"))
|
|
63
|
+
store_path: str = field(default_factory=lambda: _env("STORE_PATH", "./qdrant_data"))
|
|
64
|
+
|
|
65
|
+
# --- Vendor-neutral provider blocks (mem0-style {provider, config}) ---
|
|
66
|
+
# These are how the engine will eventually select implementations without
|
|
67
|
+
# ever naming a vendor. The factories that consume them arrive in Step 5.5.
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def llm(self) -> dict:
|
|
71
|
+
return {
|
|
72
|
+
"provider": self.llm_backend,
|
|
73
|
+
"config": {"api_key": self.llm_api_key, "model": self.model},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def embedder(self) -> dict:
|
|
78
|
+
return {
|
|
79
|
+
"provider": self.embed_backend,
|
|
80
|
+
"config": {"api_key": self.embed_api_key, "embed_dim": self.embed_dim},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def vector_store(self) -> dict:
|
|
85
|
+
return {
|
|
86
|
+
"provider": self.store_backend,
|
|
87
|
+
"config": {
|
|
88
|
+
"url": self.store_url,
|
|
89
|
+
"path": self.store_path,
|
|
90
|
+
"collection": self.collection,
|
|
91
|
+
"embed_dim": self.embed_dim,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# A single shared instance importers use: `from atomir.config import settings`.
|
|
97
|
+
settings = Settings()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def make_qdrant_client():
|
|
101
|
+
"""Build a QdrantClient from settings: server mode if a URL is set, else
|
|
102
|
+
embedded/local at the configured path (zero-ops).
|
|
103
|
+
|
|
104
|
+
The qdrant SDK is imported lazily so merely importing this config module
|
|
105
|
+
never requires the optional provider dependency (offline/fake path stays
|
|
106
|
+
dependency-free). Returns a `qdrant_client.QdrantClient`.
|
|
107
|
+
"""
|
|
108
|
+
from qdrant_client import QdrantClient
|
|
109
|
+
|
|
110
|
+
if settings.store_url:
|
|
111
|
+
return QdrantClient(url=settings.store_url)
|
|
112
|
+
return QdrantClient(path=settings.store_path)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Embedder implementations.
|
|
2
|
+
|
|
3
|
+
The formal `Embedder` interface is introduced in Step 5.5; for now these are
|
|
4
|
+
concrete, duck-typed classes that all expose the same two methods:
|
|
5
|
+
`embed_passage(text) -> list[float]` and `embed_query(text) -> list[float]`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from atomir.embeddings.fake import FakeEmbedder
|
|
9
|
+
from atomir.embeddings.jina import JinaEmbedder
|
|
10
|
+
|
|
11
|
+
__all__ = ["FakeEmbedder", "JinaEmbedder"]
|