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 +67 -0
- kepta-0.1.0/PKG-INFO +115 -0
- kepta-0.1.0/README.md +87 -0
- kepta-0.1.0/pyproject.toml +46 -0
- kepta-0.1.0/src/kepta/__init__.py +39 -0
- kepta-0.1.0/src/kepta/client.py +247 -0
- kepta-0.1.0/tests/test_client.py +204 -0
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"
|