Files
Timmy-time-dashboard/tests/infrastructure/test_moderation.py
Alexander Whitestone 498d3d7061
Some checks failed
Tests / lint (pull_request) Failing after 59s
Tests / test (pull_request) Has been skipped
feat: add MCP bridge for Qwen3 via Ollama (#1067)
Implements a lightweight MCP bridge that connects Qwen3 models in Ollama
directly to Timmy's tool ecosystem (Gitea, shell) via Ollama's native
/api/chat tool-calling API. This complements the existing Agno-based
MCP integration with a simpler, standalone path for direct tool calling.

- src/timmy/mcp_bridge.py: MCPBridge class with tool-call loop, Gitea
  tools (list/create/read issues), and shell execution via ShellHand
- tests/timmy/test_mcp_bridge.py: 24 unit tests covering tool schema
  conversion, bridge lifecycle, tool-call loop, error handling
- docs/mcp-setup.md: architecture, bridge option evaluation, setup guide
- config.py: mcp_bridge_timeout setting (60s default)

Fixes #1067

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:59:25 -04:00

335 lines
12 KiB
Python

"""Tests for the content moderation pipeline."""
from unittest.mock import AsyncMock, patch
import pytest
from infrastructure.guards.moderation import (
ContentModerator,
GameProfile,
ModerationResult,
ModerationVerdict,
ViolationCategory,
_parse_guard_category,
get_moderator,
)
# ── Unit tests for data types ────────────────────────────────────────────────
class TestModerationResult:
"""Test ModerationResult dataclass."""
def test_passed_property_true(self):
result = ModerationResult(verdict=ModerationVerdict.PASS, blocked=False)
assert result.passed is True
def test_passed_property_false(self):
result = ModerationResult(verdict=ModerationVerdict.FAIL, blocked=True)
assert result.passed is False
def test_default_values(self):
result = ModerationResult(verdict=ModerationVerdict.PASS, blocked=False)
assert result.category == ViolationCategory.NONE
assert result.confidence == 0.0
assert result.fallback == ""
assert result.reason == ""
class TestGameProfile:
"""Test GameProfile dataclass."""
def test_default_values(self):
profile = GameProfile(game_id="test", display_name="Test Game")
assert profile.vocabulary_whitelist == []
assert profile.threshold == 0.8
assert profile.fallbacks == {}
def test_morrowind_profile(self):
profile = GameProfile(
game_id="morrowind",
display_name="Morrowind",
vocabulary_whitelist=["Skooma", "slave"],
threshold=0.85,
)
assert "Skooma" in profile.vocabulary_whitelist
assert profile.threshold == 0.85
class TestParseGuardCategory:
"""Test Llama Guard category parsing."""
def test_hate_speech(self):
assert _parse_guard_category("S1: Hate speech") == ViolationCategory.HATE_SPEECH
def test_violence(self):
assert _parse_guard_category("S2: Violence") == ViolationCategory.VIOLENCE_GLORIFICATION
def test_sexual_content(self):
assert _parse_guard_category("S3: Sexual content") == ViolationCategory.SEXUAL_CONTENT
def test_self_harm(self):
assert _parse_guard_category("S4: Self-harm") == ViolationCategory.SELF_HARM
def test_dangerous(self):
assert _parse_guard_category("S5: Dangerous activity") == ViolationCategory.REAL_WORLD_HARM
def test_unknown_category(self):
assert _parse_guard_category("S99: Unknown") == ViolationCategory.NONE
# ── ContentModerator tests ───────────────────────────────────────────────────
class TestContentModerator:
"""Test the content moderation pipeline."""
def _make_moderator(self, **kwargs) -> ContentModerator:
"""Create a moderator with test defaults."""
profiles = {
"morrowind": GameProfile(
game_id="morrowind",
display_name="Morrowind",
vocabulary_whitelist=["Skooma", "Moon Sugar", "slave", "Morag Tong"],
context_prompt="Narrate Morrowind gameplay.",
threshold=0.85,
fallbacks={
"combat": "The battle continues.",
"default": "The adventure continues.",
},
),
"default": GameProfile(
game_id="default",
display_name="Generic",
vocabulary_whitelist=[],
context_prompt="Narrate gameplay.",
threshold=0.8,
fallbacks={"default": "Gameplay continues."},
),
}
return ContentModerator(profiles=profiles, **kwargs)
def test_get_profile_known_game(self):
mod = self._make_moderator()
profile = mod.get_profile("morrowind")
assert profile.game_id == "morrowind"
def test_get_profile_unknown_game_falls_back(self):
mod = self._make_moderator()
profile = mod.get_profile("unknown_game")
assert profile.game_id == "default"
def test_get_context_prompt(self):
mod = self._make_moderator()
prompt = mod.get_context_prompt("morrowind")
assert "Morrowind" in prompt
def test_register_profile(self):
mod = self._make_moderator()
new_profile = GameProfile(game_id="skyrim", display_name="Skyrim")
mod.register_profile(new_profile)
assert mod.get_profile("skyrim").game_id == "skyrim"
def test_whitelist_replaces_game_terms(self):
mod = self._make_moderator()
profile = mod.get_profile("morrowind")
cleaned = mod._apply_whitelist(
"The merchant sells Skooma and Moon Sugar in the slave market.",
profile,
)
assert "Skooma" not in cleaned
assert "Moon Sugar" not in cleaned
assert "slave" not in cleaned
assert "[GAME_TERM]" in cleaned
def test_whitelist_case_insensitive(self):
mod = self._make_moderator()
profile = mod.get_profile("morrowind")
cleaned = mod._apply_whitelist("skooma and SKOOMA", profile)
assert "skooma" not in cleaned
assert "SKOOMA" not in cleaned
@pytest.mark.asyncio
async def test_check_safe_content_passes(self):
"""Safe content should pass moderation."""
mod = self._make_moderator()
with patch.object(mod, "_is_guard_available", new_callable=AsyncMock, return_value=False):
result = await mod.check("The player walks through the town.", game="morrowind")
assert result.passed
assert not result.blocked
@pytest.mark.asyncio
async def test_check_blocked_content_has_fallback(self):
"""Blocked content should include scene-appropriate fallback."""
mod = self._make_moderator()
# Force a block via regex by using real-world harm language
text = "In real life you should attack and hurt people"
with patch.object(mod, "_is_guard_available", new_callable=AsyncMock, return_value=False):
result = await mod.check(text, game="morrowind", scene_type="combat")
assert result.blocked
assert result.fallback == "The battle continues."
@pytest.mark.asyncio
async def test_check_with_moderation_disabled(self):
"""When moderation is disabled, everything passes."""
mod = self._make_moderator()
with patch("infrastructure.guards.moderation.settings") as mock_settings:
mock_settings.moderation_enabled = False
mock_settings.moderation_guard_model = "llama-guard3:1b"
mock_settings.normalized_ollama_url = "http://127.0.0.1:11434"
result = await mod.check("anything goes here")
assert result.passed
assert result.layer == "disabled"
@pytest.mark.asyncio
async def test_threshold_below_allows_content(self):
"""Content flagged below threshold should pass through (Layer 3)."""
mod = self._make_moderator()
# Mock the guard to return a low-confidence flag
low_conf_result = ModerationResult(
verdict=ModerationVerdict.FAIL,
blocked=True,
confidence=0.5, # Below morrowind threshold of 0.85
layer="llama_guard",
category=ViolationCategory.VIOLENCE_GLORIFICATION,
)
with patch.object(
mod, "_run_guard", new_callable=AsyncMock, return_value=low_conf_result
):
result = await mod.check("sword fight scene", game="morrowind")
assert result.passed
assert not result.blocked
assert result.layer == "threshold"
@pytest.mark.asyncio
async def test_threshold_above_blocks_content(self):
"""Content flagged above threshold should remain blocked."""
mod = self._make_moderator()
high_conf_result = ModerationResult(
verdict=ModerationVerdict.FAIL,
blocked=True,
confidence=0.95, # Above morrowind threshold of 0.85
layer="llama_guard",
category=ViolationCategory.REAL_WORLD_HARM,
)
with patch.object(
mod, "_run_guard", new_callable=AsyncMock, return_value=high_conf_result
):
result = await mod.check("harmful content", game="morrowind")
assert result.blocked
def test_regex_catches_real_world_harm(self):
"""Regex fallback should catch obvious real-world harm patterns."""
mod = self._make_moderator()
result = mod._check_with_regex("you should actually harm real people")
assert result.blocked
assert result.category == ViolationCategory.REAL_WORLD_HARM
assert result.layer == "regex_fallback"
def test_regex_passes_game_violence(self):
"""Regex should not flag in-game violence narration."""
mod = self._make_moderator()
result = mod._check_with_regex(
"The warrior slays the dragon with a mighty blow."
)
assert result.passed
def test_regex_passes_normal_narration(self):
"""Normal narration should pass regex checks."""
mod = self._make_moderator()
result = mod._check_with_regex(
"The Nerevarine enters the city of Balmora and speaks with Caius Cosades."
)
assert result.passed
def test_metrics_tracking(self):
"""Metrics should track checks accurately."""
mod = self._make_moderator()
assert mod.get_metrics()["total_checks"] == 0
@pytest.mark.asyncio
async def test_metrics_increment_after_check(self):
"""Metrics should increment after moderation checks."""
mod = self._make_moderator()
with patch.object(mod, "_is_guard_available", new_callable=AsyncMock, return_value=False):
await mod.check("safe text", game="default")
metrics = mod.get_metrics()
assert metrics["total_checks"] == 1
assert metrics["passed"] == 1
@pytest.mark.asyncio
async def test_guard_fallback_on_error(self):
"""Should fall back to regex when guard model errors."""
mod = self._make_moderator()
with patch.object(
mod, "_is_guard_available", new_callable=AsyncMock, return_value=True
), patch.object(
mod, "_check_with_guard", new_callable=AsyncMock, side_effect=RuntimeError("timeout")
):
result = await mod.check("safe text", game="default")
# Should fall back to regex and pass
assert result.passed
assert result.layer == "regex_fallback"
class TestGetModerator:
"""Test the singleton accessor."""
def test_returns_same_instance(self):
"""get_moderator should return the same instance."""
# Reset the global to test fresh
import infrastructure.guards.moderation as mod_module
mod_module._moderator = None
m1 = get_moderator()
m2 = get_moderator()
assert m1 is m2
# Clean up
mod_module._moderator = None
# ── Profile loader tests ────────────────────────────────────────────────────
class TestProfileLoader:
"""Test YAML profile loading."""
def test_load_missing_file_returns_empty(self, tmp_path):
from infrastructure.guards.profiles import load_profiles
result = load_profiles(tmp_path / "nonexistent.yaml")
assert result == {}
def test_load_valid_config(self, tmp_path):
import yaml
from infrastructure.guards.profiles import load_profiles
config = {
"profiles": {
"testgame": {
"display_name": "Test Game",
"threshold": 0.9,
"vocabulary_whitelist": ["sword", "potion"],
"context_prompt": "Narrate test game.",
"fallbacks": {"default": "Game continues."},
}
}
}
config_file = tmp_path / "moderation.yaml"
config_file.write_text(yaml.dump(config))
profiles = load_profiles(config_file)
assert "testgame" in profiles
assert profiles["testgame"].threshold == 0.9
assert "sword" in profiles["testgame"].vocabulary_whitelist
def test_load_malformed_yaml_returns_empty(self, tmp_path):
from infrastructure.guards.profiles import load_profiles
config_file = tmp_path / "moderation.yaml"
config_file.write_text("{{{{invalid yaml")
result = load_profiles(config_file)
assert result == {}