atomir 0.1.0__py3-none-any.whl
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/__init__.py +9 -0
- atomir/api.py +68 -0
- atomir/assembly.py +41 -0
- atomir/atomic_read.py +76 -0
- atomir/client.py +64 -0
- atomir/config.py +112 -0
- atomir/embeddings/__init__.py +11 -0
- atomir/embeddings/fake.py +48 -0
- atomir/embeddings/jina.py +86 -0
- atomir/extractor.py +34 -0
- atomir/llm/__init__.py +11 -0
- atomir/llm/fake.py +46 -0
- atomir/llm/groq.py +85 -0
- atomir/llm/parsing.py +64 -0
- atomir/locking.py +26 -0
- atomir/memory.py +77 -0
- atomir/providers/__init__.py +11 -0
- atomir/providers/embedder_base.py +27 -0
- atomir/providers/factory.py +57 -0
- atomir/providers/llm_base.py +20 -0
- atomir/reconciler.py +92 -0
- atomir/store_base.py +79 -0
- atomir/stores/__init__.py +9 -0
- atomir/stores/json_store.py +143 -0
- atomir/stores/qdrant_store.py +151 -0
- atomir-0.1.0.dist-info/METADATA +155 -0
- atomir-0.1.0.dist-info/RECORD +30 -0
- atomir-0.1.0.dist-info/WHEEL +5 -0
- atomir-0.1.0.dist-info/licenses/LICENSE +21 -0
- atomir-0.1.0.dist-info/top_level.txt +1 -0
atomir/__init__.py
ADDED
|
@@ -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"
|
atomir/api.py
ADDED
|
@@ -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
|
+
}
|
atomir/assembly.py
ADDED
|
@@ -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)
|
atomir/atomic_read.py
ADDED
|
@@ -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}
|
atomir/client.py
ADDED
|
@@ -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")
|
atomir/config.py
ADDED
|
@@ -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"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Fake embedder: deterministic, offline, no API key.
|
|
2
|
+
|
|
3
|
+
Maps text -> a unit vector derived from a hash of the text. Both
|
|
4
|
+
`embed_passage` and `embed_query` call the SAME underlying function, so the
|
|
5
|
+
same text always yields the same vector — which lets exact-text lookups match
|
|
6
|
+
in tests. This is the offline stand-in that lets the whole pipeline run with no
|
|
7
|
+
keys, no network, and no cost.
|
|
8
|
+
|
|
9
|
+
Pure stdlib on purpose: the offline path should depend on nothing external.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import math
|
|
16
|
+
import random
|
|
17
|
+
|
|
18
|
+
from atomir.providers.embedder_base import Embedder
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeEmbedder(Embedder):
|
|
22
|
+
"""Deterministic hash-based embedder for offline development and tests."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, embed_dim: int = 1024) -> None:
|
|
25
|
+
self.embed_dim = embed_dim
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_config(cls, config: dict) -> "FakeEmbedder":
|
|
29
|
+
return cls(embed_dim=config.get("embed_dim", 1024))
|
|
30
|
+
|
|
31
|
+
def _embed(self, text: str) -> list[float]:
|
|
32
|
+
# Empty / whitespace-only text has no content to embed -> zero vector.
|
|
33
|
+
if not text or not text.strip():
|
|
34
|
+
return [0.0] * self.embed_dim
|
|
35
|
+
# Seed a PRNG deterministically from the text's hash, draw a Gaussian
|
|
36
|
+
# vector, and normalize to unit length (a point on the unit sphere).
|
|
37
|
+
seed = int.from_bytes(hashlib.sha256(text.encode("utf-8")).digest(), "big")
|
|
38
|
+
rng = random.Random(seed)
|
|
39
|
+
vec = [rng.gauss(0.0, 1.0) for _ in range(self.embed_dim)]
|
|
40
|
+
norm = math.sqrt(sum(x * x for x in vec)) or 1.0
|
|
41
|
+
return [x / norm for x in vec]
|
|
42
|
+
|
|
43
|
+
def embed_passage(self, text: str) -> list[float]:
|
|
44
|
+
return self._embed(text)
|
|
45
|
+
|
|
46
|
+
def embed_query(self, text: str) -> list[float]:
|
|
47
|
+
# Same call as passage: fake mode is symmetric so exact text matches.
|
|
48
|
+
return self._embed(text)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Jina embedder: ONE concrete example behind the (future) Embedder interface.
|
|
2
|
+
|
|
3
|
+
`jina-embeddings-v3` is asymmetric — passages and queries are embedded with
|
|
4
|
+
different task hints (`retrieval.passage` vs `retrieval.query`), which improves
|
|
5
|
+
retrieval quality. A symmetric provider would point both methods at the same
|
|
6
|
+
call; we keep both methods regardless so every embedder has the same shape.
|
|
7
|
+
|
|
8
|
+
Uses stdlib `urllib` (no new dependency) so simply importing this module never
|
|
9
|
+
requires a provider SDK. The network is only touched when a method is called.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
from atomir.providers.embedder_base import Embedder
|
|
19
|
+
|
|
20
|
+
_JINA_URL = "https://api.jina.ai/v1/embeddings"
|
|
21
|
+
# A named User-Agent avoids Cloudflare's default-urllib-UA block (see groq.py).
|
|
22
|
+
_USER_AGENT = "atomir/0.1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JinaEmbedder(Embedder):
|
|
26
|
+
"""Calls the Jina embeddings API. Requires an API key."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
api_key: str,
|
|
31
|
+
embed_dim: int = 1024,
|
|
32
|
+
model: str = "jina-embeddings-v3",
|
|
33
|
+
) -> None:
|
|
34
|
+
# Clear failure if a real provider is selected without its key.
|
|
35
|
+
if not api_key:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"JinaEmbedder requires an API key. Set EMBED_API_KEY, or use "
|
|
38
|
+
"EMBED_BACKEND=fake to run offline with no key."
|
|
39
|
+
)
|
|
40
|
+
self.api_key = api_key
|
|
41
|
+
self.embed_dim = embed_dim
|
|
42
|
+
self.model = model
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_config(cls, config: dict) -> "JinaEmbedder":
|
|
46
|
+
return cls(
|
|
47
|
+
api_key=config.get("api_key", ""),
|
|
48
|
+
embed_dim=config.get("embed_dim", 1024),
|
|
49
|
+
model=config.get("model", "jina-embeddings-v3"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _embed(self, text: str, task: str) -> list[float]:
|
|
53
|
+
if not text or not text.strip():
|
|
54
|
+
return [0.0] * self.embed_dim
|
|
55
|
+
payload = json.dumps(
|
|
56
|
+
{
|
|
57
|
+
"model": self.model,
|
|
58
|
+
"task": task,
|
|
59
|
+
"dimensions": self.embed_dim,
|
|
60
|
+
"input": [text],
|
|
61
|
+
}
|
|
62
|
+
).encode("utf-8")
|
|
63
|
+
req = urllib.request.Request(
|
|
64
|
+
_JINA_URL,
|
|
65
|
+
data=payload,
|
|
66
|
+
method="POST",
|
|
67
|
+
headers={
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
70
|
+
"User-Agent": _USER_AGENT,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req) as resp:
|
|
75
|
+
body = json.loads(resp.read().decode("utf-8"))
|
|
76
|
+
except urllib.error.HTTPError as e:
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
f"Jina embedding request failed: {e.code} {e.reason}"
|
|
79
|
+
) from e
|
|
80
|
+
return body["data"][0]["embedding"]
|
|
81
|
+
|
|
82
|
+
def embed_passage(self, text: str) -> list[float]:
|
|
83
|
+
return self._embed(text, "retrieval.passage")
|
|
84
|
+
|
|
85
|
+
def embed_query(self, text: str) -> list[float]:
|
|
86
|
+
return self._embed(text, "retrieval.query")
|
atomir/extractor.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Atomic WRITE, stage 1: raw text -> a list of atomic facts.
|
|
2
|
+
|
|
3
|
+
Vendor-neutral engine code: it depends ONLY on the injected `LLM` interface,
|
|
4
|
+
never on a concrete provider. Works for user messages and for agent-confirmed
|
|
5
|
+
findings alike (pass role="assistant").
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from atomir.providers.llm_base import LLM
|
|
11
|
+
|
|
12
|
+
_EXTRACT_SYSTEM = (
|
|
13
|
+
"You extract durable, ATOMIC, self-contained facts from a message.\n"
|
|
14
|
+
"Rules:\n"
|
|
15
|
+
"- Write each fact in the THIRD person (e.g. 'The user is vegetarian').\n"
|
|
16
|
+
"- ATOMIC: exactly one fact per item; split compound statements apart.\n"
|
|
17
|
+
"- SELF-CONTAINED: understandable without the original message.\n"
|
|
18
|
+
"- DURABLE: skip greetings, chit-chat, and transient remarks.\n"
|
|
19
|
+
'Return ONLY JSON of the form {"facts": ["...", "..."]}. '
|
|
20
|
+
"Use an empty list if nothing durable is present."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_facts(llm: LLM, text: str, role: str = "user") -> list[str]:
|
|
25
|
+
"""Return atomic facts extracted from `text`. Empty list if none/blank."""
|
|
26
|
+
text = (text or "").strip()
|
|
27
|
+
if not text:
|
|
28
|
+
return []
|
|
29
|
+
# All framing lives in the system prompt; the user content is the raw
|
|
30
|
+
# message only (keeps the content clean for any LLM to reason over).
|
|
31
|
+
system = f"{_EXTRACT_SYSTEM}\n(The message was written by the {role}.)"
|
|
32
|
+
result = llm.chat_json(system, text)
|
|
33
|
+
facts = result.get("facts", [])
|
|
34
|
+
return [f.strip() for f in facts if isinstance(f, str) and f.strip()]
|
atomir/llm/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""LLM implementations.
|
|
2
|
+
|
|
3
|
+
The formal `LLM` interface is introduced in Step 5.5; for now these are
|
|
4
|
+
concrete, duck-typed classes exposing `chat_json`, `chat_text`, and `judge`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from atomir.llm.fake import FakeLLM
|
|
8
|
+
from atomir.llm.groq import GroqLLM
|
|
9
|
+
from atomir.llm.parsing import extract_json
|
|
10
|
+
|
|
11
|
+
__all__ = ["FakeLLM", "GroqLLM", "extract_json"]
|
atomir/llm/fake.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Fake LLM: deterministic, offline, no API key.
|
|
2
|
+
|
|
3
|
+
The third leg of the offline tripod (with the fake embedder and JSON store).
|
|
4
|
+
Its default `chat_json` performs a naive "atomic extraction" — splitting the
|
|
5
|
+
user text into sentence-like fragments under a `facts` key — so the write
|
|
6
|
+
pipeline produces something real in fake mode. Tests can inject `canned` output
|
|
7
|
+
to pin an exact response for any task.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from atomir.providers.llm_base import LLM
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _split_sentences(text: str) -> list[str]:
|
|
18
|
+
parts = re.split(r"(?<=[.!?])\s+|\n+", text.strip())
|
|
19
|
+
return [p.strip() for p in parts if p.strip()]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FakeLLM(LLM):
|
|
23
|
+
"""Deterministic stand-in LLM. Runs with no key, no network."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, canned: dict | str | None = None) -> None:
|
|
26
|
+
# canned dict -> returned by chat_json; canned str -> returned by chat_text.
|
|
27
|
+
self.canned = canned
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_config(cls, config: dict) -> "FakeLLM":
|
|
31
|
+
return cls()
|
|
32
|
+
|
|
33
|
+
def chat_json(self, system: str, user: str) -> dict:
|
|
34
|
+
if isinstance(self.canned, dict):
|
|
35
|
+
return dict(self.canned)
|
|
36
|
+
# Default: naive atomic extraction so the write pipeline works offline.
|
|
37
|
+
return {"facts": _split_sentences(user)}
|
|
38
|
+
|
|
39
|
+
def chat_text(self, system: str, user: str) -> str:
|
|
40
|
+
if isinstance(self.canned, str):
|
|
41
|
+
return self.canned
|
|
42
|
+
return user.strip()
|
|
43
|
+
|
|
44
|
+
def judge(self, rubric: str, content: str) -> tuple[bool, str]:
|
|
45
|
+
# Deterministic accept, so eval harness runs offline.
|
|
46
|
+
return True, "fake judge: accepted deterministically"
|