kepta 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.
kepta-0.1.0/.gitignore ADDED
@@ -0,0 +1,67 @@
1
+ # deps
2
+ node_modules/
3
+ .pnp/
4
+ .pnp.js
5
+
6
+ # build
7
+ dist/
8
+ out/
9
+ release/
10
+ build/*
11
+ !build/icon.icns
12
+ !build/icon.png
13
+ !build/icon.iconset
14
+ !build/icon.iconset/**
15
+ *.tsbuildinfo
16
+
17
+ # os
18
+ .DS_Store
19
+ Thumbs.db
20
+
21
+ # logs
22
+ *.log
23
+ npm-debug.log*
24
+ yarn-debug.log*
25
+ yarn-error.log*
26
+
27
+ # env & secrets
28
+ .env
29
+ .env.local
30
+ .env.*.local
31
+ *.pem
32
+ *.key
33
+ firebase-applet-config.json
34
+ !firebase-applet-config.example.json
35
+
36
+ # electron
37
+ *.dmg
38
+ *.blockmap
39
+ *.zip
40
+ *.snap
41
+ *.AppImage
42
+ *.exe
43
+
44
+ # editor
45
+ .vscode/
46
+ .idea/
47
+ *.swp
48
+ *.swo
49
+ *~
50
+
51
+ # caches
52
+ .cache/
53
+ .parcel-cache/
54
+ project.tar.gz
55
+ *.tar.gz
56
+
57
+ # lokale Agent-Konfiguration
58
+ .mcp.json
59
+
60
+ # coverage
61
+ coverage/
62
+
63
+ # Python
64
+ __pycache__/
65
+ *.egg-info/
66
+ .pytest_cache/
67
+ *.py[cod]
kepta-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.5
2
+ Name: kepta
3
+ Version: 0.1.0
4
+ Summary: Python-Client für KEPTA — das lokale Gedächtnis für KI-Agenten. Ohne Cloud, ohne Konto.
5
+ Project-URL: Homepage, https://github.com/DamianTodorovic/kepta
6
+ Project-URL: Repository, https://github.com/DamianTodorovic/kepta
7
+ Project-URL: Issues, https://github.com/DamianTodorovic/kepta/issues
8
+ Project-URL: Changelog, https://github.com/DamianTodorovic/kepta/blob/main/CHANGELOG.md
9
+ Author-email: KEPTA <hello@kepta.app>
10
+ License: MIT
11
+ Keywords: agents,ai,knowledge-graph,local-first,mcp,memory,privacy,rag
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # kepta
30
+
31
+ **Python-Client für [KEPTA](https://github.com/DamianTodorovic/kepta) — dem lokalen Gedächtnis für KI-Agenten.**
32
+
33
+ Deine Agenten vergessen dich nach jedem Gespräch. KEPTA behebt das — mit einer SQLite-Datei auf deinem Rechner. Kein Konto, keine Cloud, keine Telemetrie.
34
+
35
+ Dieses Paket ist der **Client, nicht die App**. KEPTA läuft als Desktop-Anwendung auf demselben Gerät; hier verbindest du dich damit.
36
+
37
+ ```bash
38
+ pip install kepta
39
+ ```
40
+
41
+ ## In dreißig Sekunden
42
+
43
+ ```python
44
+ from kepta import KeptaClient
45
+
46
+ kepta = KeptaClient() # findet die laufende Instanz von allein
47
+
48
+ kepta.save("Rezept Carbonara", "Guanciale, Pecorino, Eigelb. Keine Sahne.", tags=["kochen"])
49
+
50
+ for hit in kepta.search("was koche ich mit Nudeln"):
51
+ print(f"{hit.score:.2f} {hit.memory.title}")
52
+ ```
53
+
54
+ Das Wort *Nudeln* steht in der Notiz nicht. Gefunden wird sie trotzdem — die Suche kombiniert Volltext, Vektoren und Wissensgraph per Reciprocal Rank Fusion.
55
+
56
+ ## Warum das interessant ist
57
+
58
+ **Dasselbe Gedächtnis wie deine Agenten.** Claude Desktop und Cursor sprechen über MCP mit derselben Datenbank. Was dein Python-Skript schreibt, weiß Claude in der nächsten Antwort.
59
+
60
+ **Erinnerungen altern.** Jede hat Typ, Gültigkeit und Konfidenz. Zieht jemand um, verdrängt die neue Adresse die alte — die alte bleibt als Historie und fällt im Ranking ab. Widersprüche stapeln sich nicht.
61
+
62
+ ```python
63
+ alt = kepta.save("Wohnort", "Alex wohnt in Hamburg.")
64
+ kepta.update(alt.id, valid_to=1788400000000) # abgelaufen ab diesem Zeitpunkt
65
+ kepta.save("Wohnort aktuell", "Alex wohnt jetzt in Leipzig.")
66
+
67
+ m = kepta.list()[0]
68
+ m.is_expired, m.is_superseded # Zustand direkt am Objekt
69
+ ```
70
+
71
+ **Keine Abhängigkeiten.** Nur die Standardbibliothek. Ein Gedächtnis, das Privatsphäre verspricht, sollte keinen fremden Code in deinen Prozess holen.
72
+
73
+ ## Die Verbindung finden
74
+
75
+ `KeptaClient()` sucht in dieser Reihenfolge:
76
+
77
+ 1. Umgebungsvariable `KEPTA_URL`
78
+ 2. `~/.kepta/endpoint.json` — die Adressdatei, die KEPTA beim Start schreibt
79
+ 3. `http://127.0.0.1:3000` als Rückfall für den Entwicklungsmodus
80
+
81
+ Schritt 2 ist der wichtige: Die gepackte App wählt einen zufälligen Port. Explizit geht natürlich auch:
82
+
83
+ ```python
84
+ kepta = KeptaClient("http://127.0.0.1:52341")
85
+ ```
86
+
87
+ Läuft nichts, bekommst du keinen kryptischen Netzwerkfehler, sondern einen Satz, der sagt, was zu tun ist:
88
+
89
+ ```python
90
+ if not kepta.is_alive():
91
+ print("KEPTA läuft nicht — App starten oder KEPTA_URL setzen.")
92
+ ```
93
+
94
+ ## Was der Client kann
95
+
96
+ | Methode | Zweck |
97
+ |---|---|
98
+ | `health()` · `is_alive()` | Status, Version, Anzahl Knoten |
99
+ | `list(trash=False)` | Alle Erinnerungen oder den Papierkorb |
100
+ | `search(query, top_k, tags, type, scope)` | Hybride Suche mit temporaler Gewichtung |
101
+ | `save(title, content, …)` | Anlegen — Typ, Tags, Konfidenz, Gültigkeit |
102
+ | `update(id, **felder)` | Ändern; `valid_to=` statt `validTo=` |
103
+ | `delete(id, permanent=False)` | Papierkorb, auf Wunsch endgültig |
104
+ | `restore(id)` | Aus dem Papierkorb zurückholen |
105
+ | `graph()` | Entitäten und Relationen |
106
+
107
+ `Memory` und `SearchHit` sind eingefrorene Dataclasses mit Typannotationen. `SearchHit` zeigt neben dem Gesamtwert auch die Einzelspuren `vector_score` und `lexical_score`.
108
+
109
+ ## KEPTA installieren
110
+
111
+ Die App gibt es für macOS, Windows und Linux unter [Releases](https://github.com/DamianTodorovic/kepta/releases) — jeweils Intel und ARM. MIT-Lizenz, kostenlos.
112
+
113
+ ## Lizenz
114
+
115
+ MIT
kepta-0.1.0/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # kepta
2
+
3
+ **Python-Client für [KEPTA](https://github.com/DamianTodorovic/kepta) — dem lokalen Gedächtnis für KI-Agenten.**
4
+
5
+ Deine Agenten vergessen dich nach jedem Gespräch. KEPTA behebt das — mit einer SQLite-Datei auf deinem Rechner. Kein Konto, keine Cloud, keine Telemetrie.
6
+
7
+ Dieses Paket ist der **Client, nicht die App**. KEPTA läuft als Desktop-Anwendung auf demselben Gerät; hier verbindest du dich damit.
8
+
9
+ ```bash
10
+ pip install kepta
11
+ ```
12
+
13
+ ## In dreißig Sekunden
14
+
15
+ ```python
16
+ from kepta import KeptaClient
17
+
18
+ kepta = KeptaClient() # findet die laufende Instanz von allein
19
+
20
+ kepta.save("Rezept Carbonara", "Guanciale, Pecorino, Eigelb. Keine Sahne.", tags=["kochen"])
21
+
22
+ for hit in kepta.search("was koche ich mit Nudeln"):
23
+ print(f"{hit.score:.2f} {hit.memory.title}")
24
+ ```
25
+
26
+ Das Wort *Nudeln* steht in der Notiz nicht. Gefunden wird sie trotzdem — die Suche kombiniert Volltext, Vektoren und Wissensgraph per Reciprocal Rank Fusion.
27
+
28
+ ## Warum das interessant ist
29
+
30
+ **Dasselbe Gedächtnis wie deine Agenten.** Claude Desktop und Cursor sprechen über MCP mit derselben Datenbank. Was dein Python-Skript schreibt, weiß Claude in der nächsten Antwort.
31
+
32
+ **Erinnerungen altern.** Jede hat Typ, Gültigkeit und Konfidenz. Zieht jemand um, verdrängt die neue Adresse die alte — die alte bleibt als Historie und fällt im Ranking ab. Widersprüche stapeln sich nicht.
33
+
34
+ ```python
35
+ alt = kepta.save("Wohnort", "Alex wohnt in Hamburg.")
36
+ kepta.update(alt.id, valid_to=1788400000000) # abgelaufen ab diesem Zeitpunkt
37
+ kepta.save("Wohnort aktuell", "Alex wohnt jetzt in Leipzig.")
38
+
39
+ m = kepta.list()[0]
40
+ m.is_expired, m.is_superseded # Zustand direkt am Objekt
41
+ ```
42
+
43
+ **Keine Abhängigkeiten.** Nur die Standardbibliothek. Ein Gedächtnis, das Privatsphäre verspricht, sollte keinen fremden Code in deinen Prozess holen.
44
+
45
+ ## Die Verbindung finden
46
+
47
+ `KeptaClient()` sucht in dieser Reihenfolge:
48
+
49
+ 1. Umgebungsvariable `KEPTA_URL`
50
+ 2. `~/.kepta/endpoint.json` — die Adressdatei, die KEPTA beim Start schreibt
51
+ 3. `http://127.0.0.1:3000` als Rückfall für den Entwicklungsmodus
52
+
53
+ Schritt 2 ist der wichtige: Die gepackte App wählt einen zufälligen Port. Explizit geht natürlich auch:
54
+
55
+ ```python
56
+ kepta = KeptaClient("http://127.0.0.1:52341")
57
+ ```
58
+
59
+ Läuft nichts, bekommst du keinen kryptischen Netzwerkfehler, sondern einen Satz, der sagt, was zu tun ist:
60
+
61
+ ```python
62
+ if not kepta.is_alive():
63
+ print("KEPTA läuft nicht — App starten oder KEPTA_URL setzen.")
64
+ ```
65
+
66
+ ## Was der Client kann
67
+
68
+ | Methode | Zweck |
69
+ |---|---|
70
+ | `health()` · `is_alive()` | Status, Version, Anzahl Knoten |
71
+ | `list(trash=False)` | Alle Erinnerungen oder den Papierkorb |
72
+ | `search(query, top_k, tags, type, scope)` | Hybride Suche mit temporaler Gewichtung |
73
+ | `save(title, content, …)` | Anlegen — Typ, Tags, Konfidenz, Gültigkeit |
74
+ | `update(id, **felder)` | Ändern; `valid_to=` statt `validTo=` |
75
+ | `delete(id, permanent=False)` | Papierkorb, auf Wunsch endgültig |
76
+ | `restore(id)` | Aus dem Papierkorb zurückholen |
77
+ | `graph()` | Entitäten und Relationen |
78
+
79
+ `Memory` und `SearchHit` sind eingefrorene Dataclasses mit Typannotationen. `SearchHit` zeigt neben dem Gesamtwert auch die Einzelspuren `vector_score` und `lexical_score`.
80
+
81
+ ## KEPTA installieren
82
+
83
+ Die App gibt es für macOS, Windows und Linux unter [Releases](https://github.com/DamianTodorovic/kepta/releases) — jeweils Intel und ARM. MIT-Lizenz, kostenlos.
84
+
85
+ ## Lizenz
86
+
87
+ MIT
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kepta"
7
+ version = "0.1.0"
8
+ description = "Python-Client für KEPTA — das lokale Gedächtnis für KI-Agenten. Ohne Cloud, ohne Konto."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "KEPTA", email = "hello@kepta.app" }]
13
+ keywords = ["memory", "ai", "agents", "mcp", "local-first", "privacy", "knowledge-graph", "rag"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
26
+ "Typing :: Typed",
27
+ ]
28
+ # Bewusst ohne Abhängigkeiten: nur die Standardbibliothek. Ein Gedächtnis, das
29
+ # Privatsphäre verspricht, sollte keinen fremden Code in deinen Prozess holen.
30
+ dependencies = []
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=7"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/DamianTodorovic/kepta"
37
+ Repository = "https://github.com/DamianTodorovic/kepta"
38
+ Issues = "https://github.com/DamianTodorovic/kepta/issues"
39
+ Changelog = "https://github.com/DamianTodorovic/kepta/blob/main/CHANGELOG.md"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/kepta"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ pythonpath = ["src"]
@@ -0,0 +1,39 @@
1
+ """KEPTA — lokales Gedächtnis für KI-Agenten, von Python aus.
2
+
3
+ Dieses Paket ist der Client, nicht die App. KEPTA selbst läuft als Desktop-App
4
+ auf demselben Rechner; hier verbindest du dich damit.
5
+
6
+ from kepta import KeptaClient
7
+
8
+ kepta = KeptaClient() # findet die laufende Instanz von allein
9
+ kepta.save("Wohnort", "Alex wohnt in Hamburg.", tags=["personal"])
10
+ for hit in kepta.search("wo wohnt Alex"):
11
+ print(hit.memory.title, hit.score)
12
+
13
+ Alles bleibt auf dem Gerät: Der Server lauscht nur auf 127.0.0.1.
14
+ """
15
+
16
+ from .client import (
17
+ DEFAULT_URL,
18
+ KeptaClient,
19
+ KeptaError,
20
+ Memory,
21
+ MemoryType,
22
+ SearchHit,
23
+ data_dir,
24
+ discover_url,
25
+ )
26
+
27
+ __all__ = [
28
+ "KeptaClient",
29
+ "KeptaError",
30
+ "Memory",
31
+ "MemoryType",
32
+ "SearchHit",
33
+ "discover_url",
34
+ "data_dir",
35
+ "DEFAULT_URL",
36
+ "__version__",
37
+ ]
38
+
39
+ __version__ = "0.1.0"
@@ -0,0 +1,247 @@
1
+ """Python-Client für ein lokal laufendes KEPTA.
2
+
3
+ KEPTA selbst ist eine Desktop-App (Electron). Dieses Paket installiert sie nicht —
4
+ es spricht mit der HTTP-API der laufenden Instanz, damit Python-Agenten dasselbe
5
+ Gedächtnis nutzen wie Claude Desktop oder Cursor.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Iterable, Literal
16
+ from urllib.error import HTTPError, URLError
17
+ from urllib.parse import urlencode
18
+ from urllib.request import Request, urlopen
19
+
20
+ MemoryType = Literal["semantic", "episodic", "procedural"]
21
+
22
+ DEFAULT_URL = "http://127.0.0.1:3000"
23
+ ENDPOINT_FILE = "endpoint.json"
24
+
25
+
26
+ class KeptaError(RuntimeError):
27
+ """KEPTA war nicht erreichbar oder hat einen Fehler zurückgegeben."""
28
+
29
+
30
+ def data_dir() -> Path:
31
+ """Datenverzeichnis von KEPTA. `KEPTA_DATA_DIR` hat Vorrang, sonst ~/.kepta."""
32
+ override = os.environ.get("KEPTA_DATA_DIR")
33
+ return Path(override) if override else Path.home() / ".kepta"
34
+
35
+
36
+ def discover_url() -> str:
37
+ """Findet die laufende Instanz.
38
+
39
+ Reihenfolge: `KEPTA_URL`, dann die Adressdatei, die der Server beim Start
40
+ schreibt, sonst der Entwicklungs-Standardport. Die gepackte App wählt einen
41
+ zufälligen Port — ohne die Datei wäre sie nicht auffindbar.
42
+ """
43
+ env = os.environ.get("KEPTA_URL")
44
+ if env:
45
+ return env.rstrip("/")
46
+ try:
47
+ raw = (data_dir() / ENDPOINT_FILE).read_text(encoding="utf-8")
48
+ url = json.loads(raw).get("url")
49
+ if isinstance(url, str) and url:
50
+ return url.rstrip("/")
51
+ except (OSError, ValueError):
52
+ pass
53
+ return DEFAULT_URL
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Memory:
58
+ """Eine Erinnerung. Zeitstempel sind Millisekunden seit Epoch."""
59
+
60
+ id: str
61
+ title: str
62
+ content: str
63
+ tags: list[str] = field(default_factory=list)
64
+ type: MemoryType = "semantic"
65
+ scope: str = "user"
66
+ confidence: float | None = None
67
+ valid_from: int | None = None
68
+ valid_to: int | None = None
69
+ superseded_by: str | None = None
70
+ deleted_at: int | None = None
71
+ created_at: int | None = None
72
+ updated_at: int | None = None
73
+
74
+ @property
75
+ def is_expired(self) -> bool:
76
+ """Gültigkeit abgelaufen? Solche Treffer wertet KEPTA im Ranking ab."""
77
+ return self.valid_to is not None and self.valid_to < time.time() * 1000
78
+
79
+ @property
80
+ def is_superseded(self) -> bool:
81
+ """Wurde durch eine neuere Erinnerung ersetzt."""
82
+ return bool(self.superseded_by)
83
+
84
+ @classmethod
85
+ def from_api(cls, d: dict[str, Any]) -> "Memory":
86
+ return cls(
87
+ id=str(d.get("id", "")),
88
+ title=str(d.get("title", "")),
89
+ content=str(d.get("content", "")),
90
+ tags=list(d.get("tags") or []),
91
+ type=d.get("type") or "semantic",
92
+ scope=d.get("scope") or "user",
93
+ confidence=d.get("confidence"),
94
+ valid_from=d.get("validFrom"),
95
+ valid_to=d.get("validTo"),
96
+ superseded_by=d.get("supersededBy"),
97
+ deleted_at=d.get("deletedAt"),
98
+ created_at=d.get("createdAt"),
99
+ updated_at=d.get("updatedAt"),
100
+ )
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class SearchHit:
105
+ """Ein Suchtreffer mit den Einzelwerten der Suchspuren."""
106
+
107
+ memory: Memory
108
+ score: float
109
+ vector_score: float = 0.0
110
+ lexical_score: float = 0.0
111
+
112
+ @classmethod
113
+ def from_api(cls, d: dict[str, Any]) -> "SearchHit":
114
+ return cls(
115
+ memory=Memory.from_api(d.get("memory") or {}),
116
+ score=float(d.get("score") or 0.0),
117
+ vector_score=float(d.get("cosineScore") or 0.0),
118
+ lexical_score=float(d.get("bm25Score") or 0.0),
119
+ )
120
+
121
+
122
+ class KeptaClient:
123
+ """Sprechverbindung zu einer laufenden KEPTA-Instanz.
124
+
125
+ >>> kepta = KeptaClient()
126
+ >>> kepta.save("Rezept Carbonara", "Guanciale, Pecorino, Eigelb.", tags=["kochen"])
127
+ >>> [h.memory.title for h in kepta.search("was koche ich mit Nudeln")]
128
+ """
129
+
130
+ def __init__(self, url: str | None = None, timeout: float = 20.0) -> None:
131
+ self.url = (url or discover_url()).rstrip("/")
132
+ self.timeout = timeout
133
+
134
+ # ---------- HTTP ----------
135
+
136
+ def _request(self, method: str, path: str, body: Any = None, params: dict[str, Any] | None = None) -> Any:
137
+ target = f"{self.url}{path}"
138
+ if params:
139
+ cleaned = {k: v for k, v in params.items() if v is not None}
140
+ if cleaned:
141
+ target += "?" + urlencode(cleaned)
142
+ data = json.dumps(body).encode("utf-8") if body is not None else None
143
+ req = Request(target, data=data, method=method)
144
+ req.add_header("Content-Type", "application/json")
145
+ try:
146
+ with urlopen(req, timeout=self.timeout) as res:
147
+ raw = res.read().decode("utf-8")
148
+ return json.loads(raw) if raw else None
149
+ except HTTPError as e:
150
+ detail = e.read().decode("utf-8", "replace")[:400]
151
+ raise KeptaError(f"{method} {path} scheiterte ({e.code}): {detail}") from e
152
+ except (URLError, TimeoutError) as e:
153
+ raise KeptaError(
154
+ f"KEPTA unter {self.url} nicht erreichbar. Läuft die App? "
155
+ f"Alternativ KEPTA_URL setzen. Ursache: {e}"
156
+ ) from e
157
+
158
+ # ---------- Lesen ----------
159
+
160
+ def health(self) -> dict[str, Any]:
161
+ """Statusabfrage — Version, Anzahl Knoten, Pfad der Datenbank."""
162
+ return self._request("GET", "/api/health")
163
+
164
+ def is_alive(self) -> bool:
165
+ """True, wenn eine Instanz antwortet."""
166
+ try:
167
+ return bool(self.health().get("ok"))
168
+ except KeptaError:
169
+ return False
170
+
171
+ def list(self, *, trash: bool = False) -> list[Memory]:
172
+ """Alle Erinnerungen. Mit `trash=True` stattdessen den Papierkorb."""
173
+ data = self._request("GET", "/api/memories", params={"trash": "1" if trash else None})
174
+ return [Memory.from_api(d) for d in (data or [])]
175
+
176
+ def search(
177
+ self,
178
+ query: str,
179
+ *,
180
+ top_k: int = 5,
181
+ tags: Iterable[str] | None = None,
182
+ type: MemoryType | None = None,
183
+ scope: str | None = None,
184
+ ) -> list[SearchHit]:
185
+ """Hybride Suche: Volltext, Vektoren und Wissensgraph, per RRF verschmolzen."""
186
+ body: dict[str, Any] = {"query": query, "topK": max(1, min(int(top_k), 100))}
187
+ if tags:
188
+ body["tags"] = list(tags)
189
+ if type:
190
+ body["type"] = type
191
+ if scope:
192
+ body["scope"] = scope
193
+ data = self._request("POST", "/api/search", body)
194
+ return [SearchHit.from_api(d) for d in (data or {}).get("results", [])]
195
+
196
+ def graph(self) -> dict[str, Any]:
197
+ """Entitäten und Relationen des Wissensgraphen."""
198
+ return self._request("GET", "/api/graph")
199
+
200
+ # ---------- Schreiben ----------
201
+
202
+ def save(
203
+ self,
204
+ title: str,
205
+ content: str,
206
+ *,
207
+ tags: Iterable[str] | None = None,
208
+ type: MemoryType | None = None,
209
+ confidence: float | None = None,
210
+ valid_from: int | None = None,
211
+ valid_to: int | None = None,
212
+ ) -> Memory:
213
+ """Legt eine Erinnerung an."""
214
+ body: dict[str, Any] = {"title": title, "content": content}
215
+ if tags is not None:
216
+ body["tags"] = list(tags)
217
+ if type is not None:
218
+ body["type"] = type
219
+ if confidence is not None:
220
+ body["confidence"] = max(0.0, min(1.0, float(confidence)))
221
+ if valid_from is not None:
222
+ body["validFrom"] = valid_from
223
+ if valid_to is not None:
224
+ body["validTo"] = valid_to
225
+ data = self._request("POST", "/api/memories", body)
226
+ return Memory.from_api((data or {}).get("memory") or data or {})
227
+
228
+ def update(self, memory_id: str, **patch: Any) -> Memory:
229
+ """Ändert Felder einer Erinnerung. Schlüssel wie bei `save`."""
230
+ mapping = {"valid_from": "validFrom", "valid_to": "validTo"}
231
+ body: dict[str, Any] = {"id": memory_id}
232
+ for key, value in patch.items():
233
+ body[mapping.get(key, key)] = list(value) if key == "tags" and value is not None else value
234
+ data = self._request("POST", "/api/memories", body)
235
+ return Memory.from_api((data or {}).get("memory") or {})
236
+
237
+ def delete(self, memory_id: str, *, permanent: bool = False) -> bool:
238
+ """In den Papierkorb. Mit `permanent=True` endgültig — nicht umkehrbar."""
239
+ data = self._request(
240
+ "DELETE", f"/api/memories/{memory_id}", params={"permanent": "1" if permanent else None}
241
+ )
242
+ return bool((data or {}).get("ok"))
243
+
244
+ def restore(self, memory_id: str) -> bool:
245
+ """Holt eine Erinnerung aus dem Papierkorb zurück."""
246
+ data = self._request("POST", f"/api/memories/{memory_id}/restore")
247
+ return bool((data or {}).get("ok", True))
@@ -0,0 +1,204 @@
1
+ """Tests gegen einen Stub-Server — kein laufendes KEPTA nötig."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+
9
+ import pytest
10
+
11
+ from kepta import KeptaClient, KeptaError, Memory, SearchHit, discover_url
12
+
13
+ CALLS: list[tuple[str, str, dict | None]] = []
14
+
15
+
16
+ class Stub(BaseHTTPRequestHandler):
17
+ def log_message(self, *args): # Testlauf ruhig halten
18
+ pass
19
+
20
+ def _send(self, payload, status=200):
21
+ body = json.dumps(payload).encode()
22
+ self.send_response(status)
23
+ self.send_header("Content-Type", "application/json")
24
+ self.send_header("Content-Length", str(len(body)))
25
+ self.end_headers()
26
+ self.wfile.write(body)
27
+
28
+ def _read(self):
29
+ n = int(self.headers.get("Content-Length") or 0)
30
+ return json.loads(self.rfile.read(n)) if n else None
31
+
32
+ def do_GET(self):
33
+ CALLS.append(("GET", self.path, None))
34
+ if self.path.startswith("/api/health"):
35
+ return self._send({"ok": True, "version": "2.5.1", "count": 2})
36
+ if self.path.startswith("/api/memories"):
37
+ trash = "trash=1" in self.path
38
+ return self._send([{"id": "m2" if trash else "m1",
39
+ "title": "Papierkorb" if trash else "Wohnort",
40
+ "content": "x", "tags": ["personal"], "type": "semantic"}])
41
+ if self.path.startswith("/api/graph"):
42
+ return self._send({"entities": [], "relations": []})
43
+ return self._send({"error": "unbekannt"}, 404)
44
+
45
+ def do_POST(self):
46
+ body = self._read()
47
+ CALLS.append(("POST", self.path, body))
48
+ if self.path.startswith("/api/search"):
49
+ return self._send({"results": [
50
+ {"memory": {"id": "m1", "title": "Rezept Carbonara", "content": "Guanciale",
51
+ "tags": ["kochen"], "type": "semantic"},
52
+ "score": 0.91, "cosineScore": 0.7, "bm25Score": 0.5}
53
+ ]})
54
+ if self.path.endswith("/restore"):
55
+ return self._send({"ok": True})
56
+ if self.path.startswith("/api/memories"):
57
+ return self._send({"memory": {"id": body.get("id", "neu"), "title": body.get("title", ""),
58
+ "content": body.get("content", ""), "tags": body.get("tags", []),
59
+ "type": body.get("type", "semantic")}})
60
+ return self._send({"error": "unbekannt"}, 404)
61
+
62
+ def do_DELETE(self):
63
+ CALLS.append(("DELETE", self.path, None))
64
+ return self._send({"ok": True, "permanent": "permanent=1" in self.path})
65
+
66
+
67
+ @pytest.fixture()
68
+ def client():
69
+ CALLS.clear()
70
+ srv = HTTPServer(("127.0.0.1", 0), Stub)
71
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
72
+ yield KeptaClient(f"http://127.0.0.1:{srv.server_address[1]}")
73
+ srv.shutdown()
74
+
75
+
76
+ def test_health_und_is_alive(client):
77
+ assert client.health()["version"] == "2.5.1"
78
+ assert client.is_alive() is True
79
+
80
+
81
+ def test_list_liefert_memories(client):
82
+ memories = client.list()
83
+ assert isinstance(memories[0], Memory)
84
+ assert memories[0].title == "Wohnort"
85
+
86
+
87
+ def test_list_trash_setzt_parameter(client):
88
+ assert client.list(trash=True)[0].title == "Papierkorb"
89
+ assert "trash=1" in CALLS[-1][1]
90
+
91
+
92
+ def test_list_ohne_trash_haengt_keinen_parameter_an(client):
93
+ client.list()
94
+ assert "?" not in CALLS[-1][1]
95
+
96
+
97
+ def test_search_liefert_treffer_mit_teilwerten(client):
98
+ hits = client.search("was koche ich mit Nudeln", top_k=3)
99
+ assert isinstance(hits[0], SearchHit)
100
+ assert hits[0].memory.title == "Rezept Carbonara"
101
+ assert hits[0].score == pytest.approx(0.91)
102
+ assert hits[0].vector_score == pytest.approx(0.7)
103
+ assert CALLS[-1][2]["topK"] == 3
104
+
105
+
106
+ def test_search_begrenzt_top_k(client):
107
+ client.search("x", top_k=9999)
108
+ assert CALLS[-1][2]["topK"] == 100
109
+ client.search("x", top_k=0)
110
+ assert CALLS[-1][2]["topK"] == 1
111
+
112
+
113
+ def test_search_reicht_filter_durch(client):
114
+ client.search("x", tags=["kochen"], type="episodic", scope="agent")
115
+ body = CALLS[-1][2]
116
+ assert body["tags"] == ["kochen"] and body["type"] == "episodic" and body["scope"] == "agent"
117
+
118
+
119
+ def test_save_schickt_felder(client):
120
+ m = client.save("Titel", "Inhalt", tags=["a"], type="procedural", confidence=0.8)
121
+ assert m.title == "Titel"
122
+ body = CALLS[-1][2]
123
+ assert body["tags"] == ["a"] and body["type"] == "procedural" and body["confidence"] == 0.8
124
+
125
+
126
+ def test_save_klemmt_konfidenz_in_den_bereich(client):
127
+ client.save("t", "c", confidence=5)
128
+ assert CALLS[-1][2]["confidence"] == 1.0
129
+ client.save("t", "c", confidence=-3)
130
+ assert CALLS[-1][2]["confidence"] == 0.0
131
+
132
+
133
+ def test_update_uebersetzt_schluessel_nach_camelcase(client):
134
+ client.update("m1", valid_to=123, tags=["x"])
135
+ body = CALLS[-1][2]
136
+ assert body["id"] == "m1" and body["validTo"] == 123 and body["tags"] == ["x"]
137
+ assert "valid_to" not in body
138
+
139
+
140
+ def test_delete_standard_ist_papierkorb(client):
141
+ assert client.delete("m1") is True
142
+ assert "permanent" not in CALLS[-1][1]
143
+
144
+
145
+ def test_delete_permanent_setzt_parameter(client):
146
+ client.delete("m1", permanent=True)
147
+ assert "permanent=1" in CALLS[-1][1]
148
+
149
+
150
+ def test_restore(client):
151
+ assert client.restore("m1") is True
152
+
153
+
154
+ def test_graph(client):
155
+ assert client.graph() == {"entities": [], "relations": []}
156
+
157
+
158
+ def test_fehler_bei_nicht_erreichbarem_server():
159
+ c = KeptaClient("http://127.0.0.1:9") # Port 9 nimmt keine Verbindungen an
160
+ with pytest.raises(KeptaError, match="nicht erreichbar"):
161
+ c.health()
162
+ assert c.is_alive() is False
163
+
164
+
165
+ def test_http_fehler_wird_zu_kepta_error(client):
166
+ with pytest.raises(KeptaError, match="404"):
167
+ client._request("GET", "/api/gibtesnicht")
168
+
169
+
170
+ class TestMemory:
171
+ def test_abgelaufen(self):
172
+ assert Memory(id="1", title="t", content="c", valid_to=1).is_expired is True
173
+ assert Memory(id="1", title="t", content="c", valid_to=None).is_expired is False
174
+
175
+ def test_ersetzt(self):
176
+ assert Memory(id="1", title="t", content="c", superseded_by="m2").is_superseded is True
177
+ assert Memory(id="1", title="t", content="c").is_superseded is False
178
+
179
+ def test_from_api_faengt_fehlende_felder_ab(self):
180
+ m = Memory.from_api({})
181
+ assert m.id == "" and m.tags == [] and m.type == "semantic"
182
+
183
+
184
+ class TestDiscoverUrl:
185
+ def test_env_hat_vorrang(self, monkeypatch):
186
+ monkeypatch.setenv("KEPTA_URL", "http://beispiel.test:1234/")
187
+ assert discover_url() == "http://beispiel.test:1234"
188
+
189
+ def test_liest_adressdatei(self, monkeypatch, tmp_path):
190
+ monkeypatch.delenv("KEPTA_URL", raising=False)
191
+ monkeypatch.setenv("KEPTA_DATA_DIR", str(tmp_path))
192
+ (tmp_path / "endpoint.json").write_text(json.dumps({"url": "http://127.0.0.1:59999"}))
193
+ assert discover_url() == "http://127.0.0.1:59999"
194
+
195
+ def test_faellt_auf_standard_zurueck(self, monkeypatch, tmp_path):
196
+ monkeypatch.delenv("KEPTA_URL", raising=False)
197
+ monkeypatch.setenv("KEPTA_DATA_DIR", str(tmp_path))
198
+ assert discover_url() == "http://127.0.0.1:3000"
199
+
200
+ def test_kaputte_adressdatei_bricht_nicht(self, monkeypatch, tmp_path):
201
+ monkeypatch.delenv("KEPTA_URL", raising=False)
202
+ monkeypatch.setenv("KEPTA_DATA_DIR", str(tmp_path))
203
+ (tmp_path / "endpoint.json").write_text("kein json")
204
+ assert discover_url() == "http://127.0.0.1:3000"