kairo-code 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kairo/backend/api/agents.py +337 -16
- kairo/backend/app.py +84 -4
- kairo/backend/config.py +4 -2
- kairo/backend/models/agent.py +216 -2
- kairo/backend/models/api_key.py +4 -1
- kairo/backend/models/task.py +31 -0
- kairo/backend/models/user_provider_key.py +26 -0
- kairo/backend/schemas/agent.py +249 -2
- kairo/backend/schemas/api_key.py +3 -0
- kairo/backend/services/agent/__init__.py +52 -0
- kairo/backend/services/agent/agent_alerts_evaluation_service.py +224 -0
- kairo/backend/services/agent/agent_alerts_service.py +201 -0
- kairo/backend/services/agent/agent_commands_service.py +142 -0
- kairo/backend/services/agent/agent_crud_service.py +150 -0
- kairo/backend/services/agent/agent_events_service.py +103 -0
- kairo/backend/services/agent/agent_heartbeat_service.py +207 -0
- kairo/backend/services/agent/agent_metrics_rollup_service.py +248 -0
- kairo/backend/services/agent/agent_metrics_service.py +259 -0
- kairo/backend/services/agent/agent_service.py +315 -0
- kairo/backend/services/agent/agent_setup_service.py +180 -0
- kairo/backend/services/agent/constants.py +28 -0
- kairo/backend/services/agent_service.py +18 -102
- kairo/backend/services/api_key_service.py +23 -3
- kairo/backend/services/byok_service.py +204 -0
- kairo/backend/services/chat_service.py +398 -63
- kairo/backend/services/deep_search_service.py +159 -0
- kairo/backend/services/email_service.py +418 -19
- kairo/backend/services/few_shot_service.py +223 -0
- kairo/backend/services/post_processor.py +261 -0
- kairo/backend/services/rag_service.py +150 -0
- kairo/backend/services/task_service.py +119 -0
- kairo/backend/tests/__init__.py +1 -0
- kairo/backend/tests/e2e/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/conftest.py +389 -0
- kairo/backend/tests/e2e/agents/test_agent_alerts.py +802 -0
- kairo/backend/tests/e2e/agents/test_agent_commands.py +456 -0
- kairo/backend/tests/e2e/agents/test_agent_crud.py +455 -0
- kairo/backend/tests/e2e/agents/test_agent_events.py +415 -0
- kairo/backend/tests/e2e/agents/test_agent_heartbeat.py +520 -0
- kairo/backend/tests/e2e/agents/test_agent_metrics.py +587 -0
- kairo/backend/tests/e2e/agents/test_agent_setup.py +349 -0
- kairo/migrations/versions/010_agent_dashboard.py +246 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/METADATA +1 -1
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/RECORD +50 -16
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/top_level.txt +1 -0
- kairo_migrations/env.py +92 -0
- kairo_migrations/versions/001_add_agent_dashboard_extensions.py +450 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/WHEEL +0 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""
|
|
2
|
+
End-to-end tests for Agent Events functionality.
|
|
3
|
+
|
|
4
|
+
Tests cover:
|
|
5
|
+
- Get events
|
|
6
|
+
- Filter by event type
|
|
7
|
+
- Event logging on actions
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import datetime, timedelta, UTC
|
|
13
|
+
from httpx import AsyncClient
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
|
|
16
|
+
from backend.models.agent import Agent, AgentEvent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestGetEvents:
|
|
20
|
+
"""Tests for GET /api/agents/{agent_id}/events"""
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_get_events_empty(
|
|
24
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
25
|
+
):
|
|
26
|
+
"""Should return empty list for agent with no events."""
|
|
27
|
+
response = await client.get(
|
|
28
|
+
f"/api/agents/{test_agent.id}/events", headers=auth_headers
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
assert response.status_code == 200
|
|
32
|
+
data = response.json()
|
|
33
|
+
assert data == []
|
|
34
|
+
|
|
35
|
+
@pytest.mark.asyncio
|
|
36
|
+
async def test_get_events_with_data(
|
|
37
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
38
|
+
):
|
|
39
|
+
"""Should return events for agent."""
|
|
40
|
+
# Create some events
|
|
41
|
+
await agent_factory.create_event(
|
|
42
|
+
test_agent.id, event_type="heartbeat", event_data={"status": "online"}
|
|
43
|
+
)
|
|
44
|
+
await agent_factory.create_event(
|
|
45
|
+
test_agent.id, event_type="connection", event_data={"action": "connected"}
|
|
46
|
+
)
|
|
47
|
+
await agent_factory.create_event(
|
|
48
|
+
test_agent.id, event_type="error", error_message="Connection timeout"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
response = await client.get(
|
|
52
|
+
f"/api/agents/{test_agent.id}/events", headers=auth_headers
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
assert response.status_code == 200
|
|
56
|
+
data = response.json()
|
|
57
|
+
assert len(data) == 3
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_get_events_returns_expected_fields(
|
|
61
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
62
|
+
):
|
|
63
|
+
"""Should return events with all expected fields."""
|
|
64
|
+
await agent_factory.create_event(
|
|
65
|
+
test_agent.id,
|
|
66
|
+
event_type="heartbeat",
|
|
67
|
+
event_data={"status": "busy", "queue_depth": 5},
|
|
68
|
+
client_ip="192.168.1.100",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
response = await client.get(
|
|
72
|
+
f"/api/agents/{test_agent.id}/events", headers=auth_headers
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
assert response.status_code == 200
|
|
76
|
+
data = response.json()
|
|
77
|
+
event = data[0]
|
|
78
|
+
|
|
79
|
+
assert "id" in event
|
|
80
|
+
assert event["event_type"] == "heartbeat"
|
|
81
|
+
assert event["event_data"]["status"] == "busy"
|
|
82
|
+
assert event["event_data"]["queue_depth"] == 5
|
|
83
|
+
assert event["client_ip"] == "192.168.1.100"
|
|
84
|
+
assert "created_at" in event
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_get_events_ordered_by_time_desc(
|
|
88
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, db_session
|
|
89
|
+
):
|
|
90
|
+
"""Should return events in descending time order (newest first)."""
|
|
91
|
+
now = datetime.now(UTC)
|
|
92
|
+
|
|
93
|
+
# Create events with different timestamps
|
|
94
|
+
for i, event_type in enumerate(["event_a", "event_b", "event_c"]):
|
|
95
|
+
event = AgentEvent(
|
|
96
|
+
id=str(uuid.uuid4()),
|
|
97
|
+
agent_id=test_agent.id,
|
|
98
|
+
event_type=event_type,
|
|
99
|
+
)
|
|
100
|
+
db_session.add(event)
|
|
101
|
+
await db_session.commit()
|
|
102
|
+
|
|
103
|
+
# Manually set created_at to control ordering
|
|
104
|
+
event.created_at = now - timedelta(minutes=i)
|
|
105
|
+
await db_session.commit()
|
|
106
|
+
|
|
107
|
+
response = await client.get(
|
|
108
|
+
f"/api/agents/{test_agent.id}/events", headers=auth_headers
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
data = response.json()
|
|
112
|
+
event_types = [e["event_type"] for e in data]
|
|
113
|
+
|
|
114
|
+
# event_a is newest, event_c is oldest
|
|
115
|
+
assert event_types == ["event_a", "event_b", "event_c"]
|
|
116
|
+
|
|
117
|
+
@pytest.mark.asyncio
|
|
118
|
+
async def test_get_events_agent_not_found(
|
|
119
|
+
self, client: AsyncClient, auth_headers: dict
|
|
120
|
+
):
|
|
121
|
+
"""Should return 404 for non-existent agent."""
|
|
122
|
+
fake_id = str(uuid.uuid4())
|
|
123
|
+
|
|
124
|
+
response = await client.get(
|
|
125
|
+
f"/api/agents/{fake_id}/events", headers=auth_headers
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
assert response.status_code == 404
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TestFilterEventsByType:
|
|
132
|
+
"""Tests for event type filtering."""
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
async def test_filter_by_event_type(
|
|
136
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
137
|
+
):
|
|
138
|
+
"""Should filter events by event_type parameter."""
|
|
139
|
+
# Create different event types
|
|
140
|
+
await agent_factory.create_event(test_agent.id, event_type="heartbeat")
|
|
141
|
+
await agent_factory.create_event(test_agent.id, event_type="heartbeat")
|
|
142
|
+
await agent_factory.create_event(test_agent.id, event_type="error")
|
|
143
|
+
await agent_factory.create_event(test_agent.id, event_type="connection")
|
|
144
|
+
|
|
145
|
+
response = await client.get(
|
|
146
|
+
f"/api/agents/{test_agent.id}/events?event_type=heartbeat",
|
|
147
|
+
headers=auth_headers,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
assert response.status_code == 200
|
|
151
|
+
data = response.json()
|
|
152
|
+
assert len(data) == 2
|
|
153
|
+
assert all(e["event_type"] == "heartbeat" for e in data)
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio
|
|
156
|
+
async def test_filter_by_error_type(
|
|
157
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
158
|
+
):
|
|
159
|
+
"""Should filter by error event type."""
|
|
160
|
+
await agent_factory.create_event(
|
|
161
|
+
test_agent.id,
|
|
162
|
+
event_type="error",
|
|
163
|
+
error_type="LLM_ERROR",
|
|
164
|
+
error_message="Model unavailable",
|
|
165
|
+
)
|
|
166
|
+
await agent_factory.create_event(test_agent.id, event_type="heartbeat")
|
|
167
|
+
|
|
168
|
+
response = await client.get(
|
|
169
|
+
f"/api/agents/{test_agent.id}/events?event_type=error",
|
|
170
|
+
headers=auth_headers,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
assert response.status_code == 200
|
|
174
|
+
data = response.json()
|
|
175
|
+
assert len(data) == 1
|
|
176
|
+
assert data[0]["error_type"] == "LLM_ERROR"
|
|
177
|
+
assert data[0]["error_message"] == "Model unavailable"
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_filter_returns_empty_for_no_match(
|
|
181
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
182
|
+
):
|
|
183
|
+
"""Should return empty list when no events match filter."""
|
|
184
|
+
await agent_factory.create_event(test_agent.id, event_type="heartbeat")
|
|
185
|
+
|
|
186
|
+
response = await client.get(
|
|
187
|
+
f"/api/agents/{test_agent.id}/events?event_type=error",
|
|
188
|
+
headers=auth_headers,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
assert response.status_code == 200
|
|
192
|
+
data = response.json()
|
|
193
|
+
assert len(data) == 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestEventsLimit:
|
|
197
|
+
"""Tests for event limit parameter."""
|
|
198
|
+
|
|
199
|
+
@pytest.mark.asyncio
|
|
200
|
+
async def test_default_limit_50(
|
|
201
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
202
|
+
):
|
|
203
|
+
"""Should return max 50 events by default."""
|
|
204
|
+
# Create 60 events
|
|
205
|
+
for _ in range(60):
|
|
206
|
+
await agent_factory.create_event(test_agent.id, event_type="heartbeat")
|
|
207
|
+
|
|
208
|
+
response = await client.get(
|
|
209
|
+
f"/api/agents/{test_agent.id}/events", headers=auth_headers
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
assert response.status_code == 200
|
|
213
|
+
data = response.json()
|
|
214
|
+
assert len(data) == 50
|
|
215
|
+
|
|
216
|
+
@pytest.mark.asyncio
|
|
217
|
+
async def test_custom_limit(
|
|
218
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
|
|
219
|
+
):
|
|
220
|
+
"""Should respect custom limit parameter."""
|
|
221
|
+
for _ in range(20):
|
|
222
|
+
await agent_factory.create_event(test_agent.id, event_type="heartbeat")
|
|
223
|
+
|
|
224
|
+
response = await client.get(
|
|
225
|
+
f"/api/agents/{test_agent.id}/events?limit=10", headers=auth_headers
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
assert response.status_code == 200
|
|
229
|
+
data = response.json()
|
|
230
|
+
assert len(data) == 10
|
|
231
|
+
|
|
232
|
+
@pytest.mark.asyncio
|
|
233
|
+
async def test_limit_max_500(
|
|
234
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
235
|
+
):
|
|
236
|
+
"""Should enforce maximum limit of 500."""
|
|
237
|
+
response = await client.get(
|
|
238
|
+
f"/api/agents/{test_agent.id}/events?limit=1000", headers=auth_headers
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Should reject limit > 500
|
|
242
|
+
assert response.status_code == 422
|
|
243
|
+
|
|
244
|
+
@pytest.mark.asyncio
|
|
245
|
+
async def test_limit_min_1(
|
|
246
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
247
|
+
):
|
|
248
|
+
"""Should enforce minimum limit of 1."""
|
|
249
|
+
response = await client.get(
|
|
250
|
+
f"/api/agents/{test_agent.id}/events?limit=0", headers=auth_headers
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
assert response.status_code == 422
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class TestEventLoggingOnActions:
|
|
257
|
+
"""Tests for automatic event logging on agent actions."""
|
|
258
|
+
|
|
259
|
+
@pytest.mark.asyncio
|
|
260
|
+
async def test_heartbeat_logs_event(
|
|
261
|
+
self, client: AsyncClient, api_key_headers: dict, auth_headers: dict,
|
|
262
|
+
test_agent: Agent
|
|
263
|
+
):
|
|
264
|
+
"""Should log heartbeat event when heartbeat is received."""
|
|
265
|
+
# Send heartbeat
|
|
266
|
+
heartbeat_payload = {
|
|
267
|
+
"agent_id": test_agent.id,
|
|
268
|
+
"status": "online",
|
|
269
|
+
"sdk_version": "1.0.0",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
await client.post(
|
|
273
|
+
"/api/agents/heartbeat", json=heartbeat_payload, headers=api_key_headers
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Check events
|
|
277
|
+
response = await client.get(
|
|
278
|
+
f"/api/agents/{test_agent.id}/events?event_type=heartbeat",
|
|
279
|
+
headers=auth_headers,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
data = response.json()
|
|
283
|
+
assert len(data) >= 1
|
|
284
|
+
|
|
285
|
+
event = data[0]
|
|
286
|
+
assert event["event_type"] == "heartbeat"
|
|
287
|
+
assert event["event_data"]["status"] == "online"
|
|
288
|
+
assert event["event_data"]["sdk_version"] == "1.0.0"
|
|
289
|
+
|
|
290
|
+
@pytest.mark.asyncio
|
|
291
|
+
async def test_command_issued_logs_event(
|
|
292
|
+
self, client: AsyncClient, auth_headers: dict, test_agent: Agent
|
|
293
|
+
):
|
|
294
|
+
"""Should log command_issued event when command is created."""
|
|
295
|
+
# Issue restart command
|
|
296
|
+
await client.post(
|
|
297
|
+
f"/api/agents/{test_agent.id}/restart", headers=auth_headers
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Check events
|
|
301
|
+
response = await client.get(
|
|
302
|
+
f"/api/agents/{test_agent.id}/events?event_type=command_issued",
|
|
303
|
+
headers=auth_headers,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
data = response.json()
|
|
307
|
+
assert len(data) >= 1
|
|
308
|
+
|
|
309
|
+
event = data[0]
|
|
310
|
+
assert event["event_type"] == "command_issued"
|
|
311
|
+
assert event["event_data"]["command_type"] == "restart"
|
|
312
|
+
|
|
313
|
+
@pytest.mark.asyncio
|
|
314
|
+
async def test_error_heartbeat_logs_error_details(
|
|
315
|
+
self, client: AsyncClient, api_key_headers: dict, auth_headers: dict,
|
|
316
|
+
test_agent: Agent
|
|
317
|
+
):
|
|
318
|
+
"""Should log error details in heartbeat event."""
|
|
319
|
+
heartbeat_payload = {
|
|
320
|
+
"agent_id": test_agent.id,
|
|
321
|
+
"status": "error",
|
|
322
|
+
"last_error": {
|
|
323
|
+
"message": "Failed to connect to database",
|
|
324
|
+
"code": "DB_CONNECTION_ERROR",
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
await client.post(
|
|
329
|
+
"/api/agents/heartbeat", json=heartbeat_payload, headers=api_key_headers
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
response = await client.get(
|
|
333
|
+
f"/api/agents/{test_agent.id}/events?event_type=heartbeat",
|
|
334
|
+
headers=auth_headers,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
data = response.json()
|
|
338
|
+
event = data[0]
|
|
339
|
+
assert event["event_data"]["status"] == "error"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class TestEventWithClientIP:
|
|
343
|
+
"""Tests for client IP tracking in events."""
|
|
344
|
+
|
|
345
|
+
@pytest.mark.asyncio
|
|
346
|
+
async def test_heartbeat_event_includes_client_ip(
|
|
347
|
+
self, client: AsyncClient, api_key_headers: dict, auth_headers: dict,
|
|
348
|
+
test_agent: Agent
|
|
349
|
+
):
|
|
350
|
+
"""Should include client IP in heartbeat event."""
|
|
351
|
+
heartbeat_payload = {
|
|
352
|
+
"agent_id": test_agent.id,
|
|
353
|
+
"status": "online",
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
await client.post(
|
|
357
|
+
"/api/agents/heartbeat", json=heartbeat_payload, headers=api_key_headers
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
response = await client.get(
|
|
361
|
+
f"/api/agents/{test_agent.id}/events?event_type=heartbeat",
|
|
362
|
+
headers=auth_headers,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
data = response.json()
|
|
366
|
+
# In test environment, client IP should be captured
|
|
367
|
+
# (actual value depends on test setup)
|
|
368
|
+
assert "client_ip" in data[0]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class TestEventCleanup:
|
|
372
|
+
"""Tests for event cleanup functionality."""
|
|
373
|
+
|
|
374
|
+
@pytest.mark.asyncio
|
|
375
|
+
async def test_cleanup_old_events(self, db_session, test_user, test_agent):
|
|
376
|
+
"""Should clean up events older than retention period."""
|
|
377
|
+
from backend.services.agent_service import AgentService
|
|
378
|
+
|
|
379
|
+
# Create old event (100 days ago)
|
|
380
|
+
old_event = AgentEvent(
|
|
381
|
+
id=str(uuid.uuid4()),
|
|
382
|
+
agent_id=test_agent.id,
|
|
383
|
+
event_type="heartbeat",
|
|
384
|
+
)
|
|
385
|
+
db_session.add(old_event)
|
|
386
|
+
await db_session.commit()
|
|
387
|
+
|
|
388
|
+
# Manually set old timestamp
|
|
389
|
+
old_event.created_at = datetime.now(UTC) - timedelta(days=100)
|
|
390
|
+
await db_session.commit()
|
|
391
|
+
|
|
392
|
+
# Create recent event
|
|
393
|
+
recent_event = AgentEvent(
|
|
394
|
+
id=str(uuid.uuid4()),
|
|
395
|
+
agent_id=test_agent.id,
|
|
396
|
+
event_type="heartbeat",
|
|
397
|
+
)
|
|
398
|
+
db_session.add(recent_event)
|
|
399
|
+
await db_session.commit()
|
|
400
|
+
|
|
401
|
+
# Run cleanup with 90-day retention
|
|
402
|
+
svc = AgentService(db_session)
|
|
403
|
+
count = await svc.cleanup_old_events(retention_days=90)
|
|
404
|
+
|
|
405
|
+
assert count == 1
|
|
406
|
+
|
|
407
|
+
# Old event should be deleted
|
|
408
|
+
stmt = select(AgentEvent).where(AgentEvent.id == old_event.id)
|
|
409
|
+
result = await db_session.execute(stmt)
|
|
410
|
+
assert result.scalar_one_or_none() is None
|
|
411
|
+
|
|
412
|
+
# Recent event should remain
|
|
413
|
+
stmt = select(AgentEvent).where(AgentEvent.id == recent_event.id)
|
|
414
|
+
result = await db_session.execute(stmt)
|
|
415
|
+
assert result.scalar_one_or_none() is not None
|