# 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.*