docs/novaberg-mem-knowledge-graph.md
Rama master · réplica de lectura del repositorio original.
# Novaberg — Gedaechtnis: Knowledge Graph
**Projekt:** Novaberg — The Nova Anima Resonance System
**Dokument:** Knowledge Graph — Entitaeten, Fakten, Entity Resolution, Bi-temporales Modell
**Stand:** 21. Oktober 2026, 00:40 UTC (`date -u`; das Fakten-Plugin gelöscht). Davor 17. April 2026, Chat 52 (Code-Alignment)
**Pfad:** `novaberg/docs/novaberg-mem-knowledge-graph.md`
**Quellen:** nova-03-k.md, nova-03-t-a.md, nova-02-m-d.md
---
## 1. Aufgabe — Strukturiertes Wissen
Das LZG speichert Erinnerungen als Fliesstext — reich an Kontext, arm an Struktur. Der Knowledge Graph ergaenzt das LZG als strukturiertes Nachschlagewerk: Entitaeten als Nodes, Fakten als Edges (Tripel). Er beantwortet Fragen, die Fliesstext nicht kann:
- "Wo wohnt Anna?" — Vorwaertssuche ueber Subjekt + Attribut
- "Wer wohnt in Nuernberg?" — Rueckwaertssuche ueber Objekt (nur Typ 1)
- "Hat sich Annas Wohnort geaendert?" — Historienvergleich ueber bi-temporale Versionierung
Das LZG ist ein Tagebuch. Der Knowledge Graph ist ein Nachschlagewerk.
---
## 2. DB-Schema
### Entitaeten (Nodes)
```sql
CREATE TABLE entitaeten (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL, -- Gedaechtnis-Partition
name VARCHAR(255) NOT NULL, -- Name der Entitaet
typ VARCHAR(50) NOT NULL, -- person, ort, organisation, tier, objekt, user
zusammenfassung TEXT, -- Generierter Cache aus Fakten
embedding VECTOR(768), -- nomic-embed-text auf Name + Zusammenfassung
aktiv BOOLEAN DEFAULT TRUE, -- Soft-Delete
last_touched TIMESTAMPTZ, -- Basis fuer Decay
wiedervorlage_am TIMESTAMPTZ, -- Fuer Pixie-Butler-Task
suchtext TSVECTOR, -- Generiert aus Name + Zusammenfassung
t_created TIMESTAMPTZ DEFAULT NOW(),
t_valid TIMESTAMPTZ,
t_invalid TIMESTAMPTZ -- NULL = noch gueltig
);
```
### Fakten (Edges)
```sql
CREATE TABLE fakten (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
subjekt_id INTEGER REFERENCES entitaeten(id),
attribut VARCHAR(255) NOT NULL, -- SCREAMING_SNAKE_CASE (WOHNT_IN, HAT_SCHWESTER)
objekt_id INTEGER REFERENCES entitaeten(id), -- Typ 1 (Referenz)
objekt_wert TEXT, -- Typ 2 (Wert)
fakt_text TEXT, -- Natuerlichsprachliche Repraesentation
embedding VECTOR(768), -- Fuer semantische Suche
aktiv BOOLEAN DEFAULT TRUE,
last_touched TIMESTAMPTZ,
t_created TIMESTAMPTZ DEFAULT NOW(),
t_valid TIMESTAMPTZ,
t_invalid TIMESTAMPTZ, -- NULL = noch gueltig
CHECK ((objekt_id IS NOT NULL AND objekt_wert IS NULL)
OR (objekt_id IS NULL AND objekt_wert IS NOT NULL))
);
```
---
## 3. Entitaets-Typen
| Typ | Beschreibung | Beispiel |
|-----|-------------|---------|
| `person` | Menschen mit Eigennamen | Anna, Julia, Max |
| `ort` | Geographische Orte | Muenchen, Nuernberg |
| `organisation` | Firmen, Vereine, Institutionen | BMW, TU Muenchen |
| `tier` | Haustiere, benannte Tiere | Bello |
| `objekt` | Benannte Gegenstaende | Der Goldene Drache (Restaurant) |
| `user` | Die User-Entitaet (eine pro Partition) | Claus |
**Referenz vs. Interface:** Nur Eigennamen werden als Entitaeten angelegt (Referenz). Gattungsbegriffe wie "Gehirn", "Kaffee", "Lernalgorithmen" sind Interfaces und werden ignoriert. Faustregel: Kann man es auf ein Namensschild schreiben? Ja = Referenz, Nein = Interface.
---
## 4. Fakten-Typen
### Typ 1 — Referenz (Entitaet → Attribut → Entitaet)
Beide Seiten sind Entitaeten. Rueckwaertssuche moeglich.
```
Anna → WOHNT_IN → Muenchen
Claus → HAT_SCHWESTER → Anna
```
"Wer wohnt in Muenchen?" → Rueckwaertssuche ueber objekt_id moeglich.
### Typ 2 — Wert (Entitaet → Attribut → Skalar)
Das Objekt ist ein Freitext-Wert, keine Entitaet. Nur Vorwaertssuche.
```
Fujiyama → HOEHE → "ueber 3700m"
Anna → ALTER → "28 Jahre"
```
**DB-Constraint:** Ein Fakt ist immer entweder Typ 1 (objekt_id gesetzt, objekt_wert NULL) oder Typ 2 (objekt_id NULL, objekt_wert gesetzt). Nie beides.
### Typ 3 — Erinnerungen (kein Graph)
Nicht alles ist ein Fakt. "Meister war frustriert wegen eines Terminkonflikts" ist eine Erinnerung — emotional, kontextuell, narrativ. Erinnerungen bleiben im LZG als Fliesstext.
---
## 5. Fakten-Pipeline
~~Der FaktenManager (`plugins/fakten_manager/manager.py`) verarbeitet Fakten aus zwei Quellen:~~ → Das Fakten-Plugin ist am 21.10.2026 gelöscht (nicht committet); dieser Abschnitt beschreibt seinen Stand. Geschrieben wird seither vom Fakten-Agenten (`agents/fakten/`), der seine Aufträge vom Dispatcher bekommt (Schreibauftrag aus der Sachlage). Der FaktenManager verarbeitete Fakten aus zwei Quellen:
### Salienz-Pfad (implizit)
Die Salienz extrahiert Fakten als Rohformat `{subjekt, schluessel, wert, typ}`. ~~Der FaktenManager transformiert ueber Konstanten-Tabellen:~~ → Der FaktenManager transformierte über diese Konstanten-Tabellen; sie sind mit dem Plugin gelöscht:
| Tabelle | Zweck | Beispiel |
|---------|-------|---------|
| `_ENTITAETS_SCHLUESSEL` | Welche Schluessel auf Entitaets-Werte hindeuten | `schwester`, `wohnort`, `haustier` |
| `_ATTRIBUT_MAP` | Schluessel → Graph-Kanten-Name | `wohnort` → `WOHNT_IN` |
| `_WERT_TYP_MAP` | Schluessel → Typ der Wert-Entitaet | `wohnort` → `ort`, `schwester` → `person` |
### Planner-Pfad (explizit)
~~Bei Management-Befehlen ("Was weisst du ueber Anna?") kommt der Auftrag bereits im M2-Format — Entitaeten und Fakten sind strukturiert.~~ → Seit dem 21.10.2026 führt ein Router-Ziel `fakten` den Planner zu den Notizen; der Fakten-Agent nimmt keine Aufträge des Planners an (`zustellart = "queue"`).
### Verarbeitungsschritte
1. **Entity Resolution** — Entitaeten gegen DB aufloesen
2. **Neue Entitaeten anlegen** — mit Embedding (nomic-embed-text)
3. **Edge Invalidation Check** — Existiert bereits ein aktiver Fakt mit gleichem Subjekt + Attribut?
- Kein bestehender Fakt → INSERT
- Gleicher Wert → nur `last_touched` aktualisieren
- Anderer Wert → alten Fakt invalidieren + neuen INSERT
4. **INSERT mit Embedding** — fakt_text-Embedding fuer semantische Suche
---
## 6. Entity Resolution
**Datei:** `memory/services/entity_resolution.py`
Entity Resolution beantwortet: Wer oder was ist gemeint? Der Service loest Namen gegen die Datenbank auf, legt neue Entitaeten an und signalisiert Rueckfrage-Bedarf.
### Algorithmus (drei Informationsquellen, von billig nach teuer)
**Schritt 0 — ICH-Aufloesung:**
```python
if name.upper() == "ICH":
→ find_by_type("user") → Claus (ID 2)
```
**Schritt 1 — Name-Match (DB, pg_trgm similarity):**
Exakte oder nahe Uebereinstimmung des Namens in der `entitaeten`-Tabelle. Schnell, deterministisch, kein LLM.
**Schritt 2 — Embedding-Match (DB + Ollama, cosine):**
Semantische Suche per pgvector. Mit Name-Plausibilitaets-Check:
```python
def _name_ist_plausibel(such_name, treffer_name):
# Exakter Match (case-insensitive): "Anna" == "anna" → ja
# Teilstring: "Anna" in "Anna-Maria" → ja
# Gleicher Anfangsbuchstabe + aehnliche Laenge (+-3): "Anna" ~ "Anne" → ja
# Komplett verschieden: "Anna" != "Max" → nein
```
Ohne den Check matchte "Anna" auf "Max" per Embedding — zwei kurze Namen mit hoher Cosine Similarity aber voellig verschiedener Bedeutung.
**Schritt 3 — Disambiguierung bei Mehrdeutigkeit:**
Wenn mehrere Kandidaten mit aehnlichem Namen existieren → Rueckfrage ueber den Responder: "Meinst du Julia aus Nuernberg oder Julia aus Augsburg?"
### Resolve-Modi
- **resolve_batch** — Liste von `{name, typ}` Paaren aufloesen (typisch nach Salienz-Extraktion)
- **resolve_single** — Einzelne Entitaet aufloesen (typisch bei Fakten-Abfragen)
### Ergebnis-Datenstruktur
```python
@dataclass
class ResolvedEntity:
name: str # Originalname aus der Extraktion
typ: str # person, ort, organisation, tier, ...
bekannte_id: int | None # DB-ID oder None
ist_neu: bool # True → muss angelegt werden
ist_referenz: bool # True → Referenz, False → Interface
braucht_klaerung: bool # True → Rueckfrage noetig
klaerungsfrage: str # Rueckfrage-Text
kandidaten: list[dict] # Bei Mehrdeutigkeit: moegliche Matches
```
---
## 7. Bi-temporales Modell
Jeder Fakt hat vier Zeitstempel:
| Feld | Beschreibung |
|------|-------------|
| `t_created` | Wann wurde der Fakt im System angelegt? (Systemzeit) |
| `t_valid` | Ab wann galt der Fakt in der Realwelt? |
| `t_invalid` | Ab wann galt der Fakt nicht mehr? (NULL = noch gueltig) |
| `aktiv` | Soft-Delete Flag (FALSE bei Invalidierung) |
**Kein Ueberschreiben — Update = Invalidieren + Neu Anlegen:**
```sql
-- Anna zieht von Nuernberg nach Muenchen
UPDATE fakten SET t_invalid = NOW(), aktiv = FALSE WHERE id = 47; -- Nuernberg
INSERT INTO fakten (...) VALUES (..., 'WOHNT_IN', ..., 'Muenchen'); -- Muenchen
```
Beide Fakten bleiben in der DB:
- "Wo wohnt Anna?" → `WHERE aktiv = TRUE` → Muenchen
- "Wo hat Anna frueher gewohnt?" → Alle Fakten → Nuernberg + Muenchen mit Zeitstempeln
**Herkunft:** Das bi-temporale Modell stammt aus der Graphiti-Architektur (Zep Labs). Es loest das Ueberschreibungsproblem durch Struktur statt durch Konfidenzwerte. Gleiches Attribut bei gleicher Entitaet → Widerspruch erkennbar durch exakten Vergleich, kein Embedding-Match noetig.
---
## 8. Abfrage
### Aktive Fakten
Embedding-basierte Suche mit aktiv-Filter:
```sql
SELECT * FROM fakten
WHERE user_id = $1
AND aktiv = TRUE
AND t_invalid IS NULL
ORDER BY embedding <=> $query_embedding
LIMIT 10;
```
### Historie
Alle Fakten zu einer Entitaet + Attribut — aktive UND inaktive. Beantwortet "Wo hat Anna frueher gewohnt?" (zeigt den invalidierten Nuernberg-Fakt und den aktuellen Muenchen-Fakt mit Zeitstempeln).
### Enricher-Hook
~~Der FaktenManager liefert Kontext ueber `enrich()`:~~ → Das Fakten-Plugin ist am 21.10.2026 gelöscht (nicht committet); sein Hook war im Enricher abgeschaltet. Gelesen wird über die Mappe (`memory/fakten_mappe.py::load_mappe`, `FaktenRepository.find_umgebung`, Endpunkt `GET /fakten/{user_id}/{entitaet_id}`). Der Hook lieferte:
```
[Fakten/Anna (person)]
HAT_SCHWESTER = Anna (seit 2026-03-24)
WOHNT_IN = Muenchen (seit 2026-03-24)
```
~~Laedt~~ → Lud alle Entitaeten des Users mit ihren aktiven Fakten, formatiert als hierarchische Liste mit Gueltigkeitsdatum.
---
## Zusammenspiel (Beispiel)
```
User: "Meine Schwester Anna ist nach Muenchen gezogen"
|
v
Salienz: facts = [{subjekt: "ICH", schluessel: "schwester", wert: "Anna", ...}]
|
v
~~FaktenManager.execute()~~ → Plugin am 21.10.2026 gelöscht (nicht committet); der Ablauf zeigt seinen Stand
|
+-- _salienz_facts_transformieren()
| → Entitaeten: [ICH(user), Anna(person), Muenchen(ort)]
| → Fakten: [ICH HAT_SCHWESTER Anna, Anna WOHNT_IN Muenchen]
|
+-- Entity Resolution
| → ICH → Claus (ID 2)
| → Anna → bestehend (ID 21) oder neu
| → Muenchen → bestehend oder neu
|
+-- Edge Invalidation
| → Anna WOHNT_IN Nuernberg (ID 47) → invalidiert
|
+-- INSERT
→ Anna WOHNT_IN Muenchen (neu, mit Embedding)
```