docs/novaberg-convention-event-model.md
Rama master · réplica de lectura del repositorio original.
# Novaberg — Event-Modell: Zwei unabhängige Akteure
**Projekt:** Novaberg — The Nova Anima Resonance System
**Dokument:** Konzept — Event-Modell (Architektur-Evolution)
**Stand:** 8. November 2026, 14:58 UTC (`date -u`; §7.2: das Warten auf `graph_run_lock` hält die Schleife nicht an — Code-Tree `10166cb`, Kennung wird nachgetragen). Davor 4. Oktober 2026, 14:16 UTC (die Auslieferung wartet auf die Wahrnehmung der Antwort, gewollt). Davor 19. September 2026, 21:27 UTC (Verweise auf das geteilte Bugregister umgestellt). Davor 14. September 2026 (der Thinker-Selbsttrigger ist live gefeuert — zwei Rohturns unter einer `turn_id`, die zweite Antwort wiederholt die erste). Davor 12. September 2026 (`nachrichten_ids` auch in `turn_gescheitert`; Antworten an andere Absender im Client). Davor: 28. August 2026 (`ausloeser_turn_id` im Zustellungs-Payload — Scheibe 4 des Lage-Konzepts). Davor: 24. August 2026 (§7a **im Betrieb belegt** — 15 Aufrufe, die Marke kommt an; sie steht aber in keiner der 120 AgentGraph-Zeilen des Protokolls). Davor: 23. August 2026 (§7a neu — `event_payload` traegt die Herkunftsmarke auch ohne Ereignis; der AgentGraph wird direkt gerufen). Davor: 16. August 2026 (gegen den Code gehalten: alle benannten Mechanismen stehen; die Migrationsliste in §9.1 unterzeichnete den Stand, §9.3 ist **nicht** umgesetzt). Davor: 15. August 2026 (`gedanke_arousal` und sein **zweiter** Erzeuger, der Thinker-Retry); davor 1. August 2026, Chat 124 (Eingangs-Queue vor Pfad 1, Turn-Marker, fire-and-forget — Migrationsschritte 6 und 7 abgeschlossen). Kern: 21. April 2026, Chat 60
**Pfad:** novaberg/docs/novaberg-convention-event-model.md
**Typ:** Convention
**Voraussetzung:** Session-Trennung (user_id × character_id), Chat 60 ✅
**Ersetzt:** Async-Block (nachbearbeitung.py), TurnOrchestrator-Vision
---
## 1. Motivation
Der bisherige HumanGraph ist ein synchroner Monolith: User schreibt → gesamte Pipeline → Antwort. Nova existiert nur als Funktion innerhalb dieses Durchlaufs. Der in Chat 59 gebaute async-Pfad (Perzeption → Enricher → Annotation) war ein erster Schritt, aber eine Sackgasse: Router → Planner im async-Pfad erzeugt Rückfragen, die niemand beantwortet, und Pending-Keys, die den synchronen Flow vergiften.
Das Event-Modell löst das Problem an der Wurzel: **User und Charakter sind zwei unabhängige Akteure, verbunden durch Events und ein gemeinsames Session-Gedächtnis.**
---
## 2. Die zwei Pfade
### Pfad 1 — User schreibt
```
API empfängt →
Perzeption(User) → Enricher → EI-Calc(User) →
Salienz(User) → Dispatcher(User, schreibt: Session-Turn + KZG + ...) →
Event erzeugen
```
Kein Router, kein Responder, kein Thinker. Der User-Pfad nimmt wahr, speichert und feuert ein Event. Die API wartet auf den Abschluss von Pfad 1 (2 LLM-Calls: Perzeption + Salienz), danach Bestätigung an den Client.
Der Session-Turn wird **am Ende** gespeichert, komplett — Text, Emotion, Arousal, Modus, Intentionen, alles. Kein nachträgliches Annotieren.
### Pfad 2 — Charakter reagiert
```
Event lesen → create_state(aus Event + Session) →
Enricher → EI-Calc(Nova) → Router → [Planner ⇄ Agent (sternförmig)] →
GV-Node → Responder → Thinker → Tribunal → [Corrector] →
Salienz(Nova) → Dispatcher(Nova, schreibt: Session-Turn + KZG + ...) →
Bei Rückfrage: Event(awaiting_user) | Sonst optional: Self-Event(continue) →
State zerstört
```
Läuft asynchron, ausgelöst durch ein Event. Der Charakter **liest den Chat** (Enricher), entscheidet was zu tun ist (Router), handelt optional (Planner → Agent), antwortet (Responder → Thinker → Tribunal), speichert (Salienz → Dispatcher), und kann optional ein weiteres Event erzeugen (Self-Trigger).
Die Antwort erreicht den Client per WebSocket.
---
## 3. Event-Infrastruktur
### 3.0 Die Eingangs-Queue vor Pfad 1 (Chat 124)
Vor der Ereignis-Queue liegt seit dem 01.08.2026 eine zweite: `prompt_queue:{user_id}:{character_id}`. Der Chat-Endpunkt **nimmt nur an** — er stempelt `empfangen_am`, reiht ein und bestätigt mit einer `nachrichten_id`. Gemessen: **0,01 s** statt 11 bis 104 Sekunden.
**Warum vor und nicht hinter Pfad 1.** Pfad 1 hält den `graph_run_lock`; eine zweite Äußerung wartet dort, und ihr Ereignis entsteht erst danach — mit rund zehn Sekunden Abstand. In der Ereignis-Queue liegt deshalb **praktisch nie mehr als ein Nutzer-Reiz**, und eine Zusammenfassung dort kann nicht greifen. Vor Pfad 1 greift sie, weil Einreihen eine Redis-Operation ist und kein Modellaufruf.
> **Umbenannt am 25.08.2026: `llm_lock` heisst jetzt `graph_run_lock`.** Der Name sagte *Sperre vor dem Sprachmodell* und meinte *ein Graphenlauf zur Zeit*; seit dem Vormittag desselben Tages traegt `services/llm_riegel.py` den echten Modell-Riegel, und die Verwechslung waere teuer geworden.
**Der Block.** Der Prompt-Consumer nimmt beim Poll, was da liegt, und schneidet die vorderste Gruppe ab: Äußerungen, deren Abstand **zum unmittelbaren Vorgänger** höchstens `EINGANG_FENSTER` (30 s) beträgt. Der Rest bleibt für den nächsten Durchlauf. Es wird **nicht gewartet** — ein Ruhefenster wäre eine Wartezeit auf jeder Antwort.
**Der Block wird als Ganzes perzipiert.** Eine Perzeption, eine Salienz, ein Satz Intentionen für das, was der Nutzer gesagt hat. Vorher wurde je Äußerung gemessen und beim Zusammenfassen alles bis auf eine Messung verworfen — der Text überlebte im Session-Verlauf, die Messwerte nicht.
**Der Turn-Marker.** `turn_laeuft:{user_id}:{character_id}`, gesetzt mit `SET NX` vom Prompt-Consumer, gelöscht vom Event-Consumer **nach** dem CharacterGraph. Solange er steht, bleiben neue Äußerungen liegen.
**Ein eigener Impuls ist auch ein Turn.** Er kommt nicht aus der Eingangs-Queue und bringt deshalb keinen Marker mit — der Event-Consumer setzt ihn beim Aufgreifen selbst. Ohne das bliebe die Eingabe während seines Durchlaufs offen.
**Und der Marker allein genügt nicht.** Ein Impuls löscht ihn am Ende seines eigenen Durchlaufs, auch wenn das Nutzer-Ereignis dahinter noch in der Ereignis-Queue liegt. Der Prompt-Consumer prüft deshalb beides: *läuft gerade etwas* (Marker) und *kommt noch etwas* (`event_wartet`). Die zweite Frage ist nicht die erste.
> Der `graph_run_lock` kann das nicht leisten: Er wird zwischen Pfad 1 und dem CharacterGraph kurz frei, und in diesen Spalt geriet am 01.08.2026 ein zweiter Durchlauf — sein Modellaufruf lief danach in einen Timeout, der Turn blieb ohne Perzeption. Der Marker umspannt beide Hälften; sein TTL ist die Notbremse gegen einen Turn, den niemand beendet.
Belegt am 01.08.2026: Eine Äußerung während eines laufenden Turns wartete 1:57 min in der Queue, wurde 558 ms nach dem Turn-Ende genommen und lief ohne Timeout durch. Drei Äußerungen mit 12 und 4 Sekunden Abstand wurden zu **einem** Prompt und in **einer** Antwort beantwortet, die alle drei Kennungen nennt.
### 3.1 Event-Queue (Redis)
Pro User-Charakter-Paar eine FIFO-Queue:
```
event_queue:{user_id}:{character_id} # Redis List
```
Jedes Event ist ein JSON-Dict:
```python
{
"event_id": "uuid",
"user_id": "meister",
"character_id": "nova",
"source": "user", # "user" | "character"
"typ": "message", # "message" | "continue" | "awaiting_user"
"payload": {}, # Frei: verbleibende Tasks, Kontext, pending_agent
"trigger_count": 0, # Self-Trigger-Zähler (Loop-Schutz: max 3)
"erstellt_am": 1713700000.0,
}
```
### 3.1.1 Wer erzeugt Events (Stand Chat 110)
| Erzeuger | `source` | `typ` | Anlass |
|---|---|---|---|
| `api/chat.py` (sync + stream) | `user` | `message` | Der Nutzer hat geschrieben. Payload traegt `turn_id` und die neun EI-Dimensionen aus `external.emotion`. |
| `services/shadow_delivery.py` | `character` | `message` | **Neu Chat 110, geaendert am 15.08.2026.** Ein Pixie-Impuls: das Wissensstueck steht in **`eigener_gedanke`**, dazu `turn_id`, `reiz_herkunft="eigener_impuls"` und der mitgebrachte Zustand des Gedankens (`gedanke_arousal`, siehe unten). ~~das Wissensstueck als `user_prompt`~~ — **`user_prompt` fehlt im Payload ganz**, siehe unten. Das Payload traegt nur, was der Stack-Eintrag wirklich hat — die uebrigen EI-Dimensionen bleiben leer statt plausibel gefuellt. **Seit dem 28.08.2026 dazu `ausloeser_turn_id`** (siehe unten). |
| `services/event_consumer.py` | `character` | `continue` | Thinker-Selbsttrigger bei Doppel-Fehlschlag. **Erbt** die `turn_id` — es ist derselbe Gedanke, nochmal versucht. **Live belegt 14.09.2026:** Unter einer `turn_id` stehen danach zwei `turn_roh`-Zeilen, und die zweite Antwort beginnt mit dem vollständigen Text der ersten (`novaberg-node-thinker.md` §3.5). |
> **Abwesend, nicht leer — der Reiz-Platz des Impuls-Ereignisses (15.08.2026).** Ein Pixie-Impuls trug den Gedanken bis dahin als `user_prompt`, auf demselben Platz, an dem sonst steht, was der Mensch gesagt hat. Seither hat er einen eigenen: `eigener_gedanke`. **`user_prompt` wird im Payload nicht gesetzt** — weder gefuellt noch leer.
>
> **Die Unterscheidung traegt die Aussage.** Ein gefuelltes Feld waere die Behauptung, jemand habe gesprochen; ein leeres Feld waere dieselbe Aussage in schwaecherer Form, denn es liesse offen, ob niemand sprach oder ob die Erhebung ausfiel. Die Abwesenheit sagt das eine, was zutrifft: **Auf diesem Weg hat niemand gesprochen.**
>
> **Kein Rueckfall auf den Reiz-Platz, wenn der Gedanke fehlt.** Ein Impuls ohne Gedanken ist ein Defekt und soll wie einer aussehen. Wer den Reiz eines Durchlaufs braucht, gleich von wem er stammt, liest ihn ueber `graph/reiz.py` statt ueber eines der beiden Felder.
**`turn_id`: erzeugen oder erben.** Wer einen neuen Turn ausloest, erzeugt eine neue `turn_id` (Chat-API, Delivery). Nur der Retry erbt sie. Die Unterscheidung ist nicht aus `source` ableitbar — Delivery und Retry tragen beide `character` —, deshalb steht die Herkunft ausdruecklich im Payload.
**Die Kennungen reisen bis in die Antwort.** Der Event-Consumer legt `turn_id` **und** `nachrichten_ids` in das `character_response`-Payload.
**Und seit dem 12.09.2026 in die Ausfallmeldung.** `turn_gescheitert` traegt dieselben `nachrichten_ids`; der Client nimmt sie aus seiner offenen Menge. Eine Antwort, die nur Kennungen nennt, die der Client nie bestaetigt bekam, gilt dort als Antwort an einen anderen Absender (`unbeobachtet`) — der Server stellt jede Antwort allen Clients des Nutzers zu.
> **Seit dem 25.08.2026 verlaesst dieses Payload den Server bei der Freigabe durch `evaluate`, nicht am Ende des Graphenlaufs** (`AUSLIEFERUNG-HINTER-DEM-NACHLAUF`). ~~Die vier Zustandsfelder aus `internal` beschreiben Novas Zustand **nach** ihrer eigenen Antwort und sind zu diesem Zeitpunkt leer — gemessen liest der Client sieben der acht Zustandsfelder ohnehin nicht.~~ → **Widerlegt am 04.10.2026 (Server-Log):** Die Auslieferung hängt am Erscheinen von `perzeption_assistant` im Stream, und ein Knoten erscheint erst, wenn er gelaufen ist. `emotion`/`arousal` aus `internal.emotion` tragen beim Senden deshalb Novas Wahrnehmung ihrer eigenen Antwort; die Antwort wartet darauf 2,3–2,7 s. Das ist gewollt: Das Avatar-Panel zeigt beim Sprechen genau diese Wahrnehmung (`novaberg-avatar_k.md` §3). Dazu kommt der Typ `turn_gescheitert`: Ein Turn ohne Antwort meldet sich, statt still zu bleiben. Die Bestaetigung des Endpunkts gibt dem Client die `nachrichten_id` seiner eigenen Aeusserung — nicht die `turn_id`, denn der Turn entsteht erst im Prompt-Consumer und kann mehrere Aeusserungen umfassen. Der Client haelt eine **Menge** offener Kennungen und schliesst alle, die eine Antwort nennt.
**Gelesen wird die Kennung aus dem Payload, nicht aus dem Ergebnis-Zustand.** Beide tragen sie, und beide liegen im selben Griffbereich — aber was der Client braucht, ist die Kennung **seiner Frage**, nicht die des Laufs, der geantwortet hat. Ein leeres Feld heisst „nicht zuordenbar" und wird als Fehler gemeldet; ein Platzhalter waere schlimmer als die Luecke, weil er gueltig aussaehe.
Der Anlass steht in `novaberg-bugs-archiv-antwortpfad.md` → `ANTWORT-OHNE-ZUORDNUNG`: Ohne die Zuordnung ordnet der Client der letzten Nachricht zu, was ankommt. Solange jeder Turn antwortet, stimmt das; faellt einer aus, verschiebt sich alles um eins.
**Die Belegung des Payloads steht im Log, und sie wird am Payload gemessen.** Acht der 24 Felder werden gezaehlt und namentlich gemeldet, wenn sie leer sind — `MEASURED_STATE_FIELDS` in `event_consumer.py` nennt sie. Die Auswahl ist eine Auswahl: Modell, Tokenzahl, Herkunft und die Emotionswerte des Nutzers bleiben ungemessen.
> **Bis zum 25.08.2026 fuehrte die Zeile eine eigene Belegungstabelle neben dem Payload, und vier ihrer acht Eintraege prueften dieselbe Groesse** — `bool(zustand_internal)` sagt, ob das Traegerobjekt existiert, nicht ob das Feld belegt ist. Sie meldete `8 von 8`, waehrend `nova_emotions_vektor` als leerer String hinausging; `Emotion.emotions_vector` traegt `""` als Vorgabewert, der Fall ist also der haeufige. Seither entsteht das Payload zuerst und die Aussage wird an ihm gemessen (`BELEGUNG-ZAEHLT-DAS-TRAEGEROBJEKT`).
>
> **Was dabei offen bleibt:** `intent`, `tone` und `gespraechs_modus` tragen Vorgabewerte, die nicht leer sind — keine Belegungspruefung unterscheidet sie von einer Messung.
**`reiz_herkunft`.** Markiert einen Reiz, den Nova sich selbst erarbeitet hat. Gelesen vom Responder (Block `[EIGENER GEDANKE]`) und vom Event-Consumer, der das Feld ins `character_response`-Payload weiterreicht, damit der Client den Impuls einfaerben kann. Fehlt das Feld, gilt der Reiz als fremd.
**`ausloeser_turn_id` (28.08.2026).** Der Turn, aus dem der Gedanke entstand — das zweite Ende der Sachlage-Bruecke (`novaberg-thinking-lage_k.md` §4, Scheibe 4). Gelesen vom Sachlage-Knoten, der daraus die Verlaufszeile des Ausloesers laedt; ohne Wert faellt er auf die Vektorsuche zurueck und markiert das. **Das Feld steht immer im Payload, auch als `None`** — derselbe Grund wie bei `gedanke_arousal`: Ein Eintrag alter Bauart hat es nicht, und Anwesenheit sagte dann nichts. Ein Erzeuger: die Zustellung; der Thinker-Wiederholungsversuch traegt keinen Impuls und deshalb auch dieses Feld nicht.
**`gedanke_arousal` (15.08.2026).** Die Erregung, in der der Gedanke gefasst wurde — sie **hebt** Novas Zustand beim Einwurf, wenn sie hoeher liegt als der geladene Wert. Der Wert stammt aus dem Stapel-Eintrag und wird im Zugriffsknoten angewandt; gelesen wird er ueber `reiz_level()` aus `graph/reiz.py`, dem einzigen Zugang.
**Zwei Erzeuger, nicht einer.** Die Zustellung legt den Wert ins Ereignis, und der **Thinker-Wiederholungsversuch** baut das Payload des Folgelaufs neu und muss ihn mitnehmen. Er liest ihn dabei ueber denselben Zugang, ueber den der Folgelauf ihn liest — eine zweite Leseart waere die Stelle, an der beide auseinanderlaufen. Laesst er ihn weg, faellt der zweite Versuch auf Novas gespeicherten Stand zurueck, **und der Ausfall ist still**: Der Zugriffsknoten meldet dann ordnungsgemaess `kein_level`, die Meldung ist richtig und ihre Ursache nicht. Von einem Stapel-Eintrag ohne Stand ist der Fall nicht zu unterscheiden.
**Das Feld steht immer im Payload, auch leer** — anders als der Reiz-Platz oben, und aus einem anderen Grund. Beim Reiz-Platz sagt die Abwesenheit *„niemand hat gesprochen"*; hier gaebe es fuer die Abwesenheit **zwei** Lesarten, weil auf dem Stapel Eintraege alter Bauart liegen, die das Feld gar nicht kennen. Ein `None` unterscheidet den Eintrag, der keinen Level hat, von dem, der noch keinen haben konnte. **Ein Vorgabewert waere hier der teuerste Fehler:** Er saehe wie eine Messung aus und hoebe Novas Zustand auf eine erfundene Zahl.
Die Pruefung liegt an der Eingangsgrenze und nicht am Wirkort: Sorte, Spanne [0,0; 1,0] und der Sonderfall, dass ein `True` in Python eine Eins ist. Ein Wert ausserhalb der Spanne wird **verworfen und gemeldet, nicht gekappt**.
### 3.2 Event-Typen
| Typ | Quelle | Bedeutung |
|-----|--------|-----------|
| `message` | user | User hat geschrieben — Charakter soll reagieren |
| `continue` | character | Charakter hat noch etwas zu sagen oder zu tun |
| `awaiting_user` | character | Agent-Rückfrage — nächstes User-Event löst Resume aus |
### 3.3 Loop-Schutz
`trigger_count` wird bei jedem Self-Event inkrementiert. Limit: 3. Der letzte Durchlauf darf kein weiteres Event erzeugen. Der Router entscheidet zusätzlich bei jedem Durchlauf, ob es etwas zu tun gibt — natürlicher Abbruch.
### 3.4 Debouncing
Wenn ein User-Event in der Queue liegt und der Consumer es noch nicht verarbeitet hat, und ein weiteres User-Event ankommt: Der Consumer wartet 2 Sekunden nach dem letzten Event, bevor er verarbeitet. Alle aufgelaufenen User-Turns sind dann in der Session sichtbar. Der Charakter reagiert einmal auf das Gesamtbild.
---
## 4. EI-Calc — Empathie-Switch
Die `source` im Event steuert, ob der User-Vektor auf Novas Emotion wirkt:
| Event-Quelle | Empathie | Decay | Erklärung |
|---|---|---|---|
| `user` | Ja — User-Vektor × α | Ja | Nova reagiert auf den User |
| `character` | Nein — kein neuer Input | Ja | Nova schreibt weiter, kein neuer Einfluss |
Im State: neues Feld `event_source: str`. Der EI-Calc prüft:
```python
if state.get("event_source") == "user":
nova_vektor = _nova_empathie_berechnen(...)
else:
nova_vektor = nova_basis_nach_decay # Nur Decay, keine Empathie
```
---
## 5. Dispatcher als zentraler Schreiber
Der Dispatcher übernimmt das Session-Turn-Speichern. Ein Schreiber für alles:
| Was | Wohin | Wann |
|---|---|---|
| Session-Turn (komplett) | Redis Session | Immer |
| KZG-Einträge | Redis KZG | Wenn Salienz segmentiert hat |
| Promotion-Trigger | Pixie-Queue | Bei hoher Salienz |
| Delegation-Trigger | Pixie-Queue | Bei DelegationsAgent-Match |
Der Session-Turn ist beim Schreiben vollständig — Text, Emotion, Arousal, Modus, Intentionen, Stil, Beziehungsdynamik. Keine nachträgliche Annotation.
**Damit fallen drei Funktionen weg:** `session_turn_store()` in `chat.py`, `session_turn_annotate()`, `session_assistant_turn_annotate()`.
---
## 6. Szenarien
### 6.1 Einfacher Chat
```
User: "Wie geht's dir?"
→ Pfad 1: Perzeption(smalltalk) → Enricher → EI-Calc → Salienz → Dispatcher → Event(message)
→ Pfad 2: Enricher(sieht Turn) → EI-Calc(Nova, Empathie) → Router(kein Management) →
GV-Node → Responder("Gut!") → Thinker → Tribunal → Salienz → Dispatcher →
kein Self-Event. Fertig.
```
### 6.2 Task mit Rückfrage
```
User: "Setz Milch auf die Einkaufsliste"
→ Pfad 1 → Event(message)
→ Pfad 2: Router(Management!) → Planner → NotizenAgent → Rückfrage: "Welche Liste?" →
Responder formuliert → Dispatcher → Event(awaiting_user)
User: "Wocheneinkauf"
→ Pfad 1 → Event(message)
→ Pfad 2: Router sieht pending + payload → Resume-Flow → Agent führt aus →
"Milch steht drauf" → Dispatcher → kein Self-Event. Fertig.
```
Die Rückfrage ist eine normale Chat-Nachricht. Der Resume-Flow greift wie heute.
### 6.3 Multi-Task (Planner löst intern)
```
User: "Setz Milch auf die Liste und erinnere mich morgen an den Zahnarzt"
→ Pfad 1 → Event(message)
→ Pfad 2: Router(Management) → Planner → NotizenAgent("Milch") → zurück zum Planner →
TimelineAgent("Zahnarzt morgen") → zurück zum Planner (fertig) →
GV-Node → Responder("Milch steht drauf und Erinnerung ist eingestellt") →
Thinker → Tribunal → Salienz → Dispatcher → kein Self-Event. Fertig.
```
Multi-Task wird im Planner-Loop gelöst (sternförmig, innerhalb eines Durchlaufs).
Self-Events sind nur für Multi-Turn — wenn Nova eine weitere Nachricht schreiben will.
### 6.4 Commitment
```
Nova (im Responder): "Ich werde das für dich recherchieren"
→ Dispatcher schreibt Turn → Self-Event(continue, payload: {commitment: "recherche"})
→ Pfad 2: Router(erkennt Commitment) → Planner → RechercheAgent/DelegationsAgent → ...
```
### 6.5 User schreibt dreimal schnell
```
User: "Hey" → Event₁
User: "Achso, ich wollte sagen..." → Event₂
User: "...dass ich morgen nicht kann" → Event₃
Consumer: 2s Debounce-Pause → alle 3 Turns in Session →
Pfad 2: Enricher sieht alle 3 → Router → Responder reagiert auf das Gesamtbild
```
### 6.6 Nova schreibt nochmal (Self-Event)
```
User: "Ich hab heute den Job verloren"
→ Pfad 1 → Event(message)
→ Pfad 2: Router(kein Management) → GV-Node → Responder(Empathie-Antwort) →
Tribunal → Salienz → Dispatcher →
Self-Event(continue, payload: {absicht: "nachfragen"})
→ Pfad 2 (Durchlauf 2): Enricher(sieht eigene Empathie-Antwort + User-Turn) →
EI-Calc(Nova, keine Empathie — source=character) →
Router(kein Management) → GV-Node → Responder("Willst du darüber reden?") →
Tribunal → Salienz → Dispatcher → kein weiteres Event. Fertig.
```
Zwei Nachrichten — erst Empathie, dann Nachfrage. Wie ein Mensch, der kurz innehält und nochmal schreibt.
### 6.7 Kein Charakter aktiv
```
User: "Hey" → Pfad 1 → Event(message) → Consumer: kein aktiver Charakter → Event bleibt/verfällt
Session und KZG sind gespeichert. Charakter-Aktivierung kann Events aufgreifen.
```
---
## 7. Event-Consumer
### 7.1 Architektur
Ein Background-Thread (wie `shadow_delivery_loop`), der die Event-Queue pollt:
```python
def event_consumer_loop(redis_client, human_graph, ...):
while not shutdown_event.is_set():
# Events für alle aktiven User:Character-Paare prüfen
event = event_queue_pop(redis_client, user_id, character_id)
if not event:
sleep(POLL_INTERVALL)
continue
# Debounce: Warten bei User-Events
if event["source"] == "user":
sleep(DEBOUNCE_DELAY)
# Weitere User-Events einsammeln (Queue leeren)
# Graph-Durchlauf
with graph_run_lock:
state = human_graph.create_state(...)
state["event_source"] = event["source"]
state["event_payload"] = event.get("payload", {})
result = charakter_graph.invoke(state)
# Self-Trigger? (Key heisst self_trigger — Chat 106, vorher stand hier
# faelschlich self_event; wer nach diesem Dokument baut, baute ins Leere)
logger.info(
"Event-Consumer: Self-Trigger im Result — vorhanden=%s, wert=%r",
"self_trigger" in result, result.get("self_trigger"),
)
if result.get("self_trigger"):
event_erzeugen(
...,
typ="continue",
source="character",
payload=result.get("self_trigger_payload", {}),
trigger_count=event["trigger_count"] + 1,
)
```
**Drei Beobachtbarkeits-Regeln (Chat 106, `090ac07`):** (1) `self_trigger` und
`self_trigger_payload` sind deklarierte `ConversationState`-Channels — ohne Deklaration
wird der Wert an der ersten Node-Grenze still verworfen (THINKER-SELFTRIGGER-KANALLOS,
live bewiesen). (2) Der Consumer loggt JEDE Ankunft, nicht nur den Erfolgsfall — sonst
ist ein toter Kanal von „kein Trigger nötig" nicht unterscheidbar. (3) Der
`MAX_SELF_TRIGGERS`-Deckel greift laut: Ein Verwurf am Limit wird mit `trigger_count`,
Limit und Paar (`user_id:character_id`) als warning geloggt — der Deckel greift bewusst,
aber nicht heimlich.
### 7.2 GPU-Locking
`graph_run_lock` wird für den gesamten Pfad-2-Durchlauf gehalten — nicht pro Node. Grund: Der Charakter-Graph ist eine kohärente Einheit (Router → Planner → Responder → Thinker → Tribunal). Feingranulares Locking würde GPU-Kontention mit Pixie riskieren.
Pfad 1 (User) braucht auch den Lock für Perzeption + Salienz. Kontention minimal: Pfad 1 ist kurz (~3–5s), Pfad 2 läuft danach.
**Das Warten auf den Riegel hält die Schleife nicht an** (seit dem Code-Tree `10166cb`, 08.11.2026, Kennung wird nachgetragen; `EVENT-LOCK-HAELT-SCHLEIFE`, Archiv). Der Riegel ist ein `threading.Lock`, und der Consumer läuft als Coroutine: `_graph_lock_acquire` versucht ihn nicht blockierend zu nehmen, alle `LOCK_RETRY_INTERVAL` (0,05 s) erneut, bis `LOCK_WAIT_TIMEOUT` (60 s) — nicht mit `with`, wie der Ablauf oben vereinfacht zeigt, und nicht über `to_thread`, weil ein abgebrochener Thread den Riegel später nähme und niemand ihn freigäbe. Nach der Frist wird das Ereignis wie bisher verworfen (*„LLM-Lock Timeout“*). Bis dahin nahm `_event_verarbeiten` ihn mit `acquire(blocking=True, timeout=60)`: Die ganze Schleife stand, und hielt der Prompt-Consumer oder die Zustellung der Impulse den Riegel über ein `await`, lief das Warten immer über die volle Frist — gemessen an den Eltern 60,0 s mit verworfenem Ereignis, am Tree größte Lücke 0,041 s (`labor/2026-11-07_eventlock_gegenprobe.txt`). **Offen:** Zwischen Erwerb und `try` steht synchroner Code; würfe er, bliebe der Riegel belegt (Fundliste, 08.11.2026). Eine Reihenfolge am Riegel gibt es nicht.
---
## 7a. `event_payload` ohne Ereignis — der direkt gerufene Graph
**Am 23.08.2026 aufgekommen und entschieden.** §7 zeigt `event_payload` als das,
was der Event-Consumer aus einem Ereignis in den Zustand hebt. Der AgentGraph
wird aber nicht über ein Ereignis erreicht: `services/shadow_delivery.py` ruft
`agent_graph.create_state(…)` direkt und lässt den Graphen im Wortthread laufen.
**Genau deshalb trug er bis dahin keine Herkunftsmarke.** `reiz_herkunft` steht
im Payload, `reiz_ist_eigener_gedanke` liest es dort — und lieferte im
AgentGraph `False`, obwohl der Reiz Novas eigener Gedanke war. Der Umbau vom
15.08.2026 hatte elf Leser im CharacterGraph umgestellt; **dieser Weg lag quer
dazu, weil er kein Ereignis ist.**
**Entschieden: `F-REIZ-1` gilt auch für den direkt gerufenen Graphen**, und das
Payload ist der Träger der Marke — unabhängig davon, ob ein Ereignis es erzeugt
hat. Der Aufrufer setzt es selbst:
```python
agent_state = agent_graph.create_state(
user_prompt = "",
eigener_gedanke = wissensstueck,
event_payload = {"reiz_herkunft": "eigener_impuls"},
…
)
```
> **Im Betrieb belegt, 24.08.2026 — und zwar an einem anderen Knoten.** Dass die
> Marke ankommt, zeigt sich dort, wo eine Entscheidung von ihr abhängt: Das
> Herkunftstor der emotionalen Gravitation liest `reiz_ist_eigener_gedanke` und
> fiel auf **15 von 15** Impuls-Turns. Wäre das Payload nicht durchgereicht
> worden, wäre es kein einziges Mal gefallen.
>
> **Der Träger ist der Zustand, nicht das Protokoll.** Über dieselben 15 Turns
> schreibt der AgentGraph **120 Zeilen** ins `pipeline_log`, und **keine** trägt
> `herkunft` oder `initiator` — von 75 markierten Zeilen des Turns stammt keine
> aus seiner Hälfte. Die Marke steht allein in der `turn_roh`-Zeile, die der
> CharacterGraph rund siebzig Sekunden später schreibt. Für die Zuordnung genügt
> das über `turn_id`; wer sie live an einer AgentGraph-Zeile ablesen will, findet
> sie dort nicht.
**Die Alternative wäre gewesen, dem AgentGraph ein echtes Ereignis zu geben** —
und sie ist verworfen, nicht übersehen: Der Zustellpfad ruft ihn bewusst direkt,
weil der Gedanke *entstehen* soll, bevor ein Ereignis den CharacterGraph
auslöst. Ein Ereignis davorzusetzen kehrte die Reihenfolge um.
> **Was der Leser daraus mitnehmen muss:** Ein `event_payload` im Zustand belegt
> **nicht**, dass ein Ereignis gelaufen ist. Es ist der Kanal für die
> Herkunftsangabe, und `event_source` bleibt die Frage danach, wer den Lauf
> ausgelöst hat. Wer die beiden gleichsetzt, liest aus dem Payload eine
> Ereignishistorie heraus, die es nicht gibt.
**Die Marke gehört ins Payload und nicht an die Rolle**, und das ist keine
Formfrage: Der Thinker-Retry trägt dieselbe Quelle `character` und ist trotzdem
die Wiederholung einer echten Nutzeräußerung.
Zeugen: `tests/test_reiz_platz.py::DerAgentGraphBekommtDieHerkunftTest` — einer
liest die Aufrufstelle aus dem Quelltext, einer die Wirkung am Zugang.
---
## 8. Was sich ändert
### 8.1 Neue Dateien
| Datei | Inhalt |
|---|---|
| `services/events.py` | Event-Queue: erzeugen, lesen, abschließen, Self-Trigger |
| `services/event_consumer.py` | Consumer-Loop, Debouncing, Graph-Aufruf |
### 8.2 Geänderte Dateien
| Datei | Änderung |
|---|---|
| `api/chat.py` | Fire-and-forget: Pfad 1 statt Vollgraph, Event erzeugen, kein SSE |
| `graph/human_graph.py` | Zwei Graphen: UserGraph (Pfad 1) + CharakterGraph (Pfad 2) |
| `graph/state.py` | Neue Felder: `event_source`, `event_payload` |
| `graph/nodes/ei_calc.py` | Empathie-Switch basierend auf `event_source` |
| `graph/nodes/dispatcher.py` | Session-Turn schreiben (komplett, kein Annotieren) |
| `api/models.py` | Response-Modell anpassen (fire-and-forget statt GespraechAntwort) |
### 8.3 Entfällt
| Datei/Feature | Grund |
|---|---|
| `services/nachbearbeitung.py` | Ersetzt durch Event-Consumer |
| SSE-Endpoint in `chat.py` | WebSocket übernimmt |
| `session_turn_annotate()` | Dispatcher schreibt komplett |
| `session_assistant_turn_annotate()` | Dispatcher schreibt komplett |
| `session_turn_store()` in chat.py | Dispatcher übernimmt |
---
## 9. Migration
### 9.1 Kein Big Bang
Der Umbau kann schrittweise erfolgen:
1. Event-Infrastruktur bauen (`services/events.py`) ✅
2. Dispatcher erweitern (Session-Turn schreiben) ✅
3. Pfad 1 als eigenen Graph extrahieren ✅ `graph/human_graph.py`
4. Pfad 2 als eigenen Graph extrahieren ✅ `graph/character_graph.py`
5. Event-Consumer bauen ✅ `services/event_consumer.py`
6. chat.py umbauen (fire-and-forget) ✅ **01.08.2026**
7. Client auf WebSocket-only umstellen ✅ **01.08.2026** — die Stufen von Pfad 1 gehen als `character_stage` über den WebSocket, der SSE-Kanal trägt nur noch die Bestätigung
8. Aufräumen (nachbearbeitung.py, SSE, Annotate-Funktionen) — **zwei von drei**
> `[gemessen]` — 16.08.2026. **Die Liste unterzeichnete den Stand:** Die Schritte 1 bis 5 waren gebaut und trugen kein Häkchen. Sie tragen jetzt eines, mit der Datei daneben.
>
> **Von Schritt 8 sind zwei Teile erledigt:** `nachbearbeitung.py` existiert nicht mehr, und die Annotate-Funktionen sind entfallen — der Turn wird seit Chat 60 vollständig in einem Zug gespeichert, was §2 als Eigenschaft von Pfad 1 bereits beschreibt.
>
> **Der SSE-Rest steht:** `api/chat.py` liefert weiterhin eine `StreamingResponse` mit `media_type="text/event-stream"`. Schritt 7 ist als erledigt vermerkt (*Client auf WebSocket-only*) — der Server-Pfad daneben ist es nicht.
Jeder Schritt ist testbar. Der alte Graph läuft parallel, bis der neue validiert ist.
### 9.2 Session-Keys
Bereits umgebaut auf `session:{user_id}:{character_id}:turns` (Chat 60). Keine weitere Migration nötig.
### 9.3 Pending-Agent-Flow
Wandert von `pending_agent:{user_id}` (Redis-Key) ins Event-Payload. Das Event `awaiting_user` speichert den Agent-Kontext. Das nächste User-Event löst den Resume aus.
> ⚠ **Am 16.08.2026 geprüft: nicht umgesetzt.** Der Redis-Schlüssel ist weiterhin der Träger, und er ist tief verdrahtet — **acht Dateien** lesen oder setzen ihn: `graph/nodes/planner.py`, `graph/nodes/router.py`, `services/event_consumer.py`, `services/shadow_delivery.py` und die Dispatches von vier Agenten.
>
> **Der Satz steht im Präsens und liest sich wie eine Beschreibung.** Er ist eine Absicht — festgehalten als `PENDING-AGENT-INS-PAYLOAD` in `novaberg-backlog.md`, damit er nicht länger wie ein Zustand aussieht.
---
## 10. Prinzipien
> **"User und Charakter sind gleichberechtigte Gesprächspartner."** Zwei unabhängige Akteure, verbunden durch Events und ein gemeinsames Session-Gedächtnis.
> **"Jeder Durchlauf tut eine Sache."** Ein Intent, eine Nachricht, ein Event. Die Iteration passiert außerhalb des Graphen.
> **"Der Dispatcher schreibt alles."** Ein Schreiber, ein Zeitpunkt, vollständige Daten. Kein nachträgliches Annotieren.
> **"Die Quelle bestimmt die Empathie."** User-Event → Empathie wirkt. Self-Event → nur Decay. Im Event kodiert, nicht in Flags.
> **"Rückfragen sind normale Nachrichten."** Kein Sonderfall, kein async-Flag, keine Pending-Key-Vergiftung. Nova fragt, der User antwortet, der Resume-Flow greift.
---
*Konzept erstellt 21. April 2026, Chat 60. Grundlage: Dual-Emotion Phase 2 (Chat 58–59), Session-Trennung (Chat 60), Async-Pfad-Analyse (Chat 59–60). Ersetzt: nachbearbeitung.py, TurnOrchestrator-Vision.*