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 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"