Spaces:
Running
Running
File size: 8,888 Bytes
534b431 dfedf76 534b431 dfedf76 534b431 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | """Tests for TranscriptAnalysisManager."""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from lyon_chatbox.cascade.config import set_config
from lyon_chatbox.cascade.transcript_analysis.base import (
EntityMatch,
TriggerMatch,
TriggerConfig,
ReactionConfig,
)
from lyon_chatbox.cascade.transcript_analysis.manager import (
TranscriptAnalysisManager,
)
@pytest.fixture(autouse=True)
def _mock_cascade_config():
"""Inject a mock CascadeConfig so entity tests don't need cascade.yaml."""
mock_cfg = MagicMock()
mock_cfg.gliner_model = "urchade/gliner_small-v2.1"
set_config(mock_cfg)
yield
set_config(None)
def _make_reaction(
name: str,
words: list[str] | None = None,
repeatable: bool = False,
params: dict | None = None,
all_groups: list[list[str]] | None = None,
entities: list[str] | None = None,
) -> ReactionConfig:
"""Build a ReactionConfig with AsyncMock callback."""
if all_groups:
trigger = TriggerConfig(all=[TriggerConfig(words=g) for g in all_groups])
else:
trigger = TriggerConfig(
words=words or [],
entities=entities or [],
)
return ReactionConfig(
name=name,
callback=AsyncMock(),
trigger=trigger,
params=params or {},
repeatable=repeatable,
)
def _cb(r: ReactionConfig) -> AsyncMock:
"""Extract the AsyncMock callback for test assertions."""
assert isinstance(r.callback, AsyncMock)
return r.callback
def _make_manager(reactions: list[ReactionConfig], **kwargs) -> TranscriptAnalysisManager:
"""Build a manager with a mock deps and no entity analyzer."""
deps = MagicMock()
mgr = TranscriptAnalysisManager(reactions, deps, **kwargs)
# Disable entity analyzer by default (tests that need it will set it explicitly)
mgr.entity_analyzer = None
return mgr
# --- Basic keyword dispatch ---
@pytest.mark.asyncio
async def test_keyword_fires_callback():
"""Dispatch callback when keyword matches."""
r = _make_reaction("music", words=["guitar"])
mgr = _make_manager([r])
await mgr.analyze_final("I love guitar")
await asyncio.sleep(0)
_cb(r).assert_called_once()
_, match = _cb(r).call_args.args[0], _cb(r).call_args.args[1]
assert isinstance(match, TriggerMatch)
assert "guitar" in match.words
@pytest.mark.asyncio
async def test_callback_receives_params():
"""Pass reaction params as kwargs to callback."""
r = _make_reaction("wave", words=["wave"], params={"direction": "left"})
mgr = _make_manager([r])
await mgr.analyze_final("let's wave")
await asyncio.sleep(0)
_cb(r).assert_called_once()
call_kwargs = _cb(r).call_args.kwargs
assert call_kwargs["direction"] == "left"
# --- Deduplication ---
@pytest.mark.asyncio
async def test_non_repeatable_fires_once():
"""Fire non-repeatable reaction only once across analyses."""
r = _make_reaction("music", words=["guitar"], repeatable=False)
mgr = _make_manager([r])
await mgr.analyze_final("I love guitar")
await asyncio.sleep(0)
await mgr.analyze_final("guitar solo")
await asyncio.sleep(0)
assert _cb(r).call_count == 1
@pytest.mark.asyncio
async def test_repeatable_keyword_fires_every_time():
"""Fire repeatable reaction on every matching analysis."""
r = _make_reaction("music", words=["guitar"], repeatable=True)
mgr = _make_manager([r])
await mgr.analyze_final("I love guitar")
await asyncio.sleep(0)
await mgr.analyze_final("guitar solo")
await asyncio.sleep(0)
assert _cb(r).call_count == 2
@pytest.mark.asyncio
async def test_reset_clears_dedup():
"""Allow non-repeatable reaction to fire again after reset."""
r = _make_reaction("music", words=["guitar"], repeatable=False)
mgr = _make_manager([r])
await mgr.analyze_final("I love guitar")
await asyncio.sleep(0)
assert _cb(r).call_count == 1
mgr.reset()
await mgr.analyze_final("guitar again")
await asyncio.sleep(0)
assert _cb(r).call_count == 2
# --- Boolean `all` triggers ---
@pytest.mark.asyncio
async def test_all_trigger_fires_when_all_match():
"""Fire when all sub-groups of an all-trigger match."""
r = _make_reaction("dance_groove", all_groups=[["danc*"], ["groov*"]])
mgr = _make_manager([r])
await mgr.analyze_final("I was dancing to a grooving beat")
await asyncio.sleep(0)
_cb(r).assert_called_once()
@pytest.mark.asyncio
async def test_all_trigger_no_fire_on_partial():
"""Do not fire when only some sub-groups match."""
r = _make_reaction("dance_groove", all_groups=[["danc*"], ["groov*"]])
mgr = _make_manager([r])
await mgr.analyze_final("I was dancing all night")
await asyncio.sleep(0)
_cb(r).assert_not_called()
@pytest.mark.asyncio
async def test_all_trigger_merged_words():
"""Merge matched words from all sub-groups into TriggerMatch."""
r = _make_reaction("dance_groove", all_groups=[["danc*"], ["groov*"]])
mgr = _make_manager([r])
await mgr.analyze_final("dancing to grooving beats")
await asyncio.sleep(0)
match = _cb(r).call_args.args[1]
assert "dancing" in match.words
assert "grooving" in match.words
# --- Entity dispatch ---
@pytest.mark.asyncio
async def test_entity_dispatch():
"""Dispatch callback when entity analyzer finds a match."""
r = _make_reaction("person_react", entities=["PERSON"])
mgr = _make_manager([r])
# Provide a fake entity analyzer that returns a match
entity_match = EntityMatch(text="Alice", label="PERSON", confidence=0.9)
async def fake_entity_analyze(text, is_final):
return [entity_match]
mock_entity_analyzer = MagicMock()
mock_entity_analyzer.analyze = AsyncMock(side_effect=fake_entity_analyze)
mgr.entity_analyzer = mock_entity_analyzer
await mgr.analyze_final("I met Alice today")
await asyncio.sleep(0)
_cb(r).assert_called_once()
match = _cb(r).call_args.args[1]
assert len(match.entities) == 1
assert match.entities[0].text == "Alice"
@pytest.mark.asyncio
async def test_entity_repeatable_dedup_by_text():
"""Deduplicate repeatable entity reactions by entity text."""
r = _make_reaction("person_react", entities=["PERSON"], repeatable=True)
mgr = _make_manager([r])
async def fake_analyze_alice(text, is_final):
return [EntityMatch(text="Alice", label="PERSON", confidence=0.9)]
async def fake_analyze_bob(text, is_final):
return [EntityMatch(text="Bob", label="PERSON", confidence=0.9)]
mock_analyzer = MagicMock()
mock_analyzer.analyze = AsyncMock(side_effect=fake_analyze_alice)
mgr.entity_analyzer = mock_analyzer
await mgr.analyze_final("I met Alice")
await asyncio.sleep(0)
# Same entity text again — should be deduped
await mgr.analyze_final("Alice is here")
await asyncio.sleep(0)
assert _cb(r).call_count == 1
# Different entity text — should fire
mock_analyzer.analyze = AsyncMock(side_effect=fake_analyze_bob)
await mgr.analyze_final("Bob arrived")
await asyncio.sleep(0)
assert _cb(r).call_count == 2
@pytest.mark.asyncio
async def test_non_repeatable_entity_fires_once():
"""Fire non-repeatable entity reaction only once total."""
r = _make_reaction("person_react", entities=["PERSON"], repeatable=False)
mgr = _make_manager([r])
mock_analyzer = MagicMock()
mock_analyzer.analyze = AsyncMock(
return_value=[EntityMatch(text="Alice", label="PERSON", confidence=0.9)]
)
mgr.entity_analyzer = mock_analyzer
await mgr.analyze_final("I met Alice")
await asyncio.sleep(0)
await mgr.analyze_final("Bob is here")
await asyncio.sleep(0)
assert _cb(r).call_count == 1
# --- Partial analysis debouncing ---
@pytest.mark.asyncio
async def test_analyze_partial_debounces():
"""Debounce rapid partial calls so only the first dispatches."""
r = _make_reaction("music", words=["guitar"], repeatable=True)
mgr = _make_manager([r])
# Three rapid calls — only first should dispatch
await mgr.analyze_partial("guitar riff")
await mgr.analyze_partial("guitar riff 2")
await mgr.analyze_partial("guitar riff 3")
await asyncio.sleep(0.1) # let tasks complete
assert _cb(r).call_count == 1
# --- Multiple independent reactions ---
@pytest.mark.asyncio
async def test_multiple_reactions_independent():
"""Fire two independent reactions from the same text."""
r1 = _make_reaction("music", words=["guitar"])
r2 = _make_reaction("dance", words=["danc*"])
mgr = _make_manager([r1, r2])
await mgr.analyze_final("I play guitar while dancing")
await asyncio.sleep(0)
_cb(r1).assert_called_once()
_cb(r2).assert_called_once()
|