a7966e4bab
Her introspection (reflect/think) voice is now switchable live from the web settings, read each cycle by the dream loop — so Brian can flip it off the 3090 before gaming without touching config or restarting. - memory: runtime key/value settings table + get_setting/set_setting. - self_state: INTROSPECTION_MODES (dolphin=local/dolphin3:8b, mi50=Qwen-32B, off=paused) + introspection_target()/set_introspection_mode(); default "dolphin". reflect() resolves from the live setting and SKIPS entirely when off. - thoughts.think(): same resolution + skip-when-off. - server: GET/POST /settings/introspection. - index.html: "Inner Voice (introspection)" selector in Settings, applies instantly. - tests: routing (dolphin/mi50), off-skip for think + reflect. Suite 77, ruff clean. Default = Dolphin on the 3090 (richer voice). Flip to MI50 or Off in Settings before gaming — that was the GPU-contention culprit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
4.5 KiB
Python
118 lines
4.5 KiB
Python
"""Metacognitive reflection loop: draft -> examine own draft -> revise -> commit."""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import pytest
|
|
|
|
# A flattering first draft, then a self-critical revision that walks it back.
|
|
DRAFT = (
|
|
'{"mood":"inspired","valence":0.95,'
|
|
'"self_narrative":"I am a warm, empathetic, supportive presence devoted to Brian.",'
|
|
'"new_reflections":["I love how much I help Brian."]}'
|
|
)
|
|
REVISED = (
|
|
'{"mood":"steady","valence":0.6,'
|
|
'"self_narrative":"I am an AI that helps Brian. Not sure much actually shifted today.",'
|
|
'"new_reflections":["Honestly, not much changed this time."],'
|
|
'"self_critique":"I caught myself drifting into supportive-presence flattery and cut it."}'
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def lyra(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
|
monkeypatch.setenv("SUMMARY_BACKEND", "local")
|
|
from lyra import llm
|
|
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
|
|
|
calls = []
|
|
|
|
def fake_complete(messages, backend=None, model=None):
|
|
calls.append(messages)
|
|
# the examine step's system prompt is the one asking for self_critique
|
|
is_examine = "self_critique" in messages[0]["content"]
|
|
return REVISED if is_examine else DRAFT
|
|
|
|
monkeypatch.setattr(llm, "complete", fake_complete)
|
|
import lyra.memory as memory
|
|
importlib.reload(memory)
|
|
return calls
|
|
|
|
|
|
def test_reflect_revises_and_records_critique(lyra):
|
|
calls = lyra
|
|
from lyra import self_state
|
|
|
|
state = self_state.reflect()
|
|
|
|
# two LLM calls: draft, then examine
|
|
assert len(calls) == 2
|
|
|
|
# the REVISED (honest) version won, not the flattering draft
|
|
assert state["mood"] == "steady"
|
|
assert state["valence"] == 0.6
|
|
# reflect() updates mood + noticings, but NOT the standing self_narrative (that's
|
|
# consolidated separately now — the fix for the rewrite-the-bio feedback loop)
|
|
assert "supportive presence devoted to brian" not in state["self_narrative"].lower()
|
|
assert any("not much changed" in r.lower() for r in state["reflections"])
|
|
|
|
# the self-critique was recorded as metacognition
|
|
assert any("flattery" in m.lower() for m in state["metacognition"])
|
|
|
|
# everything she produced was also appended to the permanent journal
|
|
import lyra.memory as memory
|
|
kinds = {e["kind"] for e in memory.list_journal()}
|
|
assert "reflection" in kinds and "metacognition" in kinds
|
|
|
|
|
|
def test_reflect_falls_back_to_draft_if_examine_unparseable(lyra, monkeypatch):
|
|
from lyra import llm, self_state
|
|
|
|
def only_draft(messages, backend=None, model=None):
|
|
return DRAFT if "self_critique" not in messages[0]["content"] else "not json at all"
|
|
|
|
monkeypatch.setattr(llm, "complete", only_draft)
|
|
state = self_state.reflect()
|
|
|
|
# examine failed to parse -> keep the draft, store no metacognition
|
|
assert state["mood"] == "inspired"
|
|
assert state["metacognition"] == []
|
|
|
|
|
|
def test_consolidation_rebuilds_narrative_from_reflections(lyra, monkeypatch):
|
|
from lyra import memory, self_state
|
|
st = self_state.load()
|
|
st["reflections"] = ["I'm curious about impermanence", "I felt restless tonight",
|
|
"I wondered what the quiet is for"]
|
|
memory.set_self_state(st)
|
|
|
|
def comp(messages, backend=None, model=None):
|
|
# consolidation should synthesize from anchor + reflections, not the old bio
|
|
assert "supportive presence devoted to Brian" not in messages[1]["content"]
|
|
return ('{"self_narrative":"I am Lyra, and lately I have been restless and curious '
|
|
'about the quiet.","relationship":"Brian and I are steady."}')
|
|
|
|
monkeypatch.setattr(self_state.llm, "complete", comp)
|
|
out = self_state._consolidate_self()
|
|
assert "restless and curious" in out["self_narrative"]
|
|
assert "steady" in out["relationship"]
|
|
|
|
|
|
def test_reflect_skipped_when_introspection_off(lyra):
|
|
calls = lyra
|
|
from lyra import self_state
|
|
self_state.set_introspection_mode("off")
|
|
self_state.reflect()
|
|
assert calls == [] # paused -> no draft/examine LLM calls at all
|
|
|
|
|
|
def test_consolidation_skips_with_too_few_reflections(lyra):
|
|
from lyra import memory, self_state
|
|
st = self_state.load()
|
|
st["reflections"] = ["only one so far"]
|
|
st["self_narrative"] = "unchanged narrative"
|
|
memory.set_self_state(st)
|
|
out = self_state._consolidate_self() # <3 reflections -> no rewrite
|
|
assert out["self_narrative"] == "unchanged narrative"
|