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()