Files
truetype 69ba824bc8 Add LLM long-term memory system
- Implement LLMMemory module with SQLite + FTS5
- Add conversation tracking with context awareness
- Track NPC reputation and interactions
- Integrate memory with LLMBrain for contextual replies
- Add comprehensive test suite (13 tests)
- Update settings with memory_db_path config
2026-02-08 21:06:01 +01:00

310 lines
10 KiB
Python

"""Tests for the LLM module (client, brain, social AI).
All tests use mocked HTTP responses — no Ollama server required.
"""
import asyncio
import json
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from modules.llm.client import (
LLMClient, LLMConfig, LLMResponse, render_template, PROMPT_TEMPLATES,
)
from modules.llm.brain import LLMBrain
from modules.llm.social_ai import SocialAI
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_mock_response(text="", tokens=10, success=True):
return LLMResponse(
text=text, model="test", tokens_used=tokens,
latency_ms=50.0, success=success,
)
def _make_json_response(data, tokens=10):
return LLMResponse(
text=json.dumps(data), model="test", tokens_used=tokens,
latency_ms=50.0, success=True,
)
# ---------------------------------------------------------------------------
# LLMClient
# ---------------------------------------------------------------------------
class TestLLMConfig:
def test_defaults(self):
cfg = LLMConfig()
assert cfg.model == "llama3.2"
assert cfg.temperature == 0.3
assert cfg.base_url == "http://localhost:11434"
class TestLLMResponse:
def test_as_json_valid(self):
r = LLMResponse(text='{"key": "value"}', success=True)
data = r.as_json()
assert data == {"key": "value"}
def test_as_json_invalid(self):
r = LLMResponse(text="not json", success=True)
assert r.as_json() is None
def test_as_json_empty(self):
r = LLMResponse(text="", success=True)
assert r.as_json() is None
class TestRenderTemplate:
def test_quest_parse_template(self):
result = render_template("quest_parse", text="Kill 5 goblins")
assert "Kill 5 goblins" in result
assert "JSON" in result
def test_social_reply_template(self):
result = render_template(
"social_reply",
message="Hello!",
character_name="Grok",
character_class="warrior",
)
assert "Grok" in result
assert "Hello!" in result
def test_decide_action_template(self):
result = render_template(
"decide_action",
room_title="Town Square",
hp=100, max_hp=100, mp=50, max_mp=50,
exits="north, south",
npcs="guard",
items="potion",
combat_state="idle",
goal="explore",
)
assert "Town Square" in result
def test_missing_variable(self):
# Should not crash, just leave placeholder
result = render_template("quest_parse")
assert isinstance(result, str)
def test_custom_template(self):
result = render_template("Hello {name}!", name="World")
assert result == "Hello World!"
class TestLLMClient:
def test_client_creation(self):
client = LLMClient(LLMConfig(model="test-model"))
assert client.config.model == "test-model"
@pytest.mark.asyncio
async def test_generate_unavailable(self):
"""When server is down, should return error response."""
client = LLMClient(LLMConfig(
base_url="http://localhost:99999",
max_retries=0,
timeout_seconds=1,
))
resp = await client.generate("test")
assert resp.success is False
assert resp.error is not None
@pytest.mark.asyncio
async def test_is_available_offline(self):
client = LLMClient(LLMConfig(base_url="http://localhost:99999"))
available = await client.is_available()
assert available is False
# ---------------------------------------------------------------------------
# LLMBrain
# ---------------------------------------------------------------------------
class TestLLMBrain:
def test_creation(self):
brain = LLMBrain(LLMConfig())
assert brain.is_available is False
assert brain._query_count == 0
@pytest.mark.asyncio
async def test_initialize_offline(self):
brain = LLMBrain(LLMConfig(base_url="http://localhost:99999"))
result = await brain.initialize()
assert result is False
assert brain.is_available is False
@pytest.mark.asyncio
async def test_parse_quest_offline_returns_none(self):
brain = LLMBrain()
brain._available = False
result = await brain.parse_quest("Kill the dragon")
assert result is None
@pytest.mark.asyncio
async def test_parse_quest_with_mock(self):
brain = LLMBrain()
brain._available = True
mock_resp = _make_json_response([
{"type": "kill", "target": "dragon", "count": 1, "location": "cave"}
])
brain.client.ask = AsyncMock(return_value=mock_resp)
result = await brain.parse_quest("Slay the dragon in the cave")
assert result is not None
assert len(result) == 1
assert result[0]["type"] == "kill"
@pytest.mark.asyncio
async def test_social_reply_offline_returns_none(self):
brain = LLMBrain()
brain._available = False
result = await brain.social_reply("Hello!")
assert result is None
@pytest.mark.asyncio
async def test_social_reply_with_mock(self):
brain = LLMBrain()
brain._available = True
# Mock generate since social_reply now uses it with context
brain.client.generate = AsyncMock(
return_value=_make_mock_response("Greetings, fellow adventurer!")
)
result = await brain.social_reply("Hello!", sender="TestPlayer")
assert result is not None
assert "adventurer" in result.lower()
@pytest.mark.asyncio
async def test_suggest_action_offline(self):
brain = LLMBrain()
brain._available = False
from core.game_state import GameState
gs = GameState()
result = await brain.suggest_action(gs)
assert result is None
@pytest.mark.asyncio
async def test_suggest_action_with_mock(self):
brain = LLMBrain()
brain._available = True
brain.client.ask = AsyncMock(
return_value=_make_mock_response("north")
)
from core.game_state import GameState, Room
gs = GameState()
gs.current_room = Room(title="Town Square", exits={"north": 0})
result = await brain.suggest_action(gs)
assert result == "north"
@pytest.mark.asyncio
async def test_parse_ambiguous_offline(self):
brain = LLMBrain()
brain._available = False
result = await brain.parse_ambiguous("Something happens")
assert result is None
@pytest.mark.asyncio
async def test_evaluate_npc_with_mock(self):
brain = LLMBrain()
brain._available = True
brain.client.ask = AsyncMock(
return_value=_make_json_response(
{"hostile": True, "confidence": 0.9, "reason": "It's a goblin"}
)
)
result = await brain.evaluate_npc("goblin", "A dark cave")
assert result is not None
assert result["hostile"] is True
def test_stats(self):
brain = LLMBrain()
stats = brain.stats()
assert stats["queries"] == 0
assert stats["available"] is False
# ---------------------------------------------------------------------------
# SocialAI
# ---------------------------------------------------------------------------
class TestSocialAI:
def _make_social_ai(self):
client = LLMClient(LLMConfig())
return SocialAI(client, character_name="Grok", character_class="warrior")
def test_detect_say(self):
ai = self._make_social_ai()
event = ai.detect_social_event("Gandalf says: Hello there!")
assert event is not None
assert event["type"] == "say"
assert event["speaker"] == "Gandalf"
def test_detect_tell(self):
ai = self._make_social_ai()
event = ai.detect_social_event("Legolas tells you: Follow me")
assert event is not None
assert event["type"] == "tell"
assert event["speaker"] == "Legolas"
def test_detect_shout(self):
ai = self._make_social_ai()
event = ai.detect_social_event("Gimli shouts: For glory!")
assert event is not None
assert event["type"] == "shout"
def test_detect_group_invite(self):
ai = self._make_social_ai()
event = ai.detect_social_event("Aragorn invites you to join a party")
assert event is not None
assert event["type"] == "group_invite"
def test_detect_no_social(self):
ai = self._make_social_ai()
event = ai.detect_social_event("The wind howls through the trees.")
assert event is None
@pytest.mark.asyncio
async def test_respond_say(self):
ai = self._make_social_ai()
ai.client.ask = AsyncMock(
return_value=_make_mock_response("Well met, friend!")
)
event = {"type": "say", "speaker": "Gandalf", "message": "Hello"}
result = await ai.respond(event)
assert result is not None
assert result.startswith("say ")
@pytest.mark.asyncio
async def test_respond_tell(self):
ai = self._make_social_ai()
ai.client.ask = AsyncMock(
return_value=_make_mock_response("On my way!")
)
event = {"type": "tell", "speaker": "Legolas", "message": "Come here"}
result = await ai.respond(event)
assert result is not None
assert result.startswith("tell Legolas")
@pytest.mark.asyncio
async def test_respond_group_invite(self):
ai = self._make_social_ai()
event = {"type": "group_invite", "speaker": "Aragorn", "message": ""}
result = await ai.respond(event)
assert result == "group accept"
@pytest.mark.asyncio
async def test_generate_emote(self):
ai = self._make_social_ai()
ai.client.generate = AsyncMock(
return_value=_make_mock_response("sharpens his sword quietly")
)
result = await ai.generate_emote("resting at the inn")
assert result is not None
assert result.startswith("emote ")