centive-sdk 2.2.0.dev5__tar.gz → 2.2.0.dev6__tar.gz

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.
Files changed (48) hide show
  1. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/CHANGELOG.md +6 -0
  2. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/PKG-INFO +1 -1
  3. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/pyproject.toml +1 -1
  4. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/models/requests.py +11 -0
  5. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/websocket_server.py +45 -2
  6. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/test_websocket_server.py +84 -0
  7. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/.gitignore +0 -0
  8. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/LICENSE +0 -0
  9. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/README.md +0 -0
  10. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/blackbox/README.md +0 -0
  11. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/blackbox/customer_app.py +0 -0
  12. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/blackbox/harness.py +0 -0
  13. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/blackbox/mock_centive_api.py +0 -0
  14. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/blackbox/test_blackbox.py +0 -0
  15. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/blackbox/test_blackbox_telemetry.py +0 -0
  16. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/examples/async_example.py +0 -0
  17. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/examples/fastapi_integration.py +0 -0
  18. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/examples/sync_example.py +0 -0
  19. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/examples/websocket_example.py +0 -0
  20. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/__init__.py +0 -0
  21. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/_http.py +0 -0
  22. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/_logging.py +0 -0
  23. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/_retry.py +0 -0
  24. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/async_client.py +0 -0
  25. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/client.py +0 -0
  26. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/config.py +0 -0
  27. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/exceptions.py +0 -0
  28. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/models/__init__.py +0 -0
  29. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/models/responses.py +0 -0
  30. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/py.typed +0 -0
  31. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/__init__.py +0 -0
  32. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/async_sessions.py +0 -0
  33. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/async_telemetry.py +0 -0
  34. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/message_accumulator.py +0 -0
  35. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/sessions.py +0 -0
  36. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/telemetry_batcher.py +0 -0
  37. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/__init__.py +0 -0
  38. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/conftest.py +0 -0
  39. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/test_client_async.py +0 -0
  40. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/test_client_sync.py +0 -0
  41. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/test_config.py +0 -0
  42. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/test_error_mapping.py +0 -0
  43. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/test_logging_redaction.py +0 -0
  44. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/test_message_accumulator.py +0 -0
  45. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/test_retry_logic.py +0 -0
  46. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/test_telemetry_batcher.py +0 -0
  47. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/test_websocket_security.py +0 -0
  48. {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev6}/tests/test_websocket_telemetry.py +0 -0
@@ -25,6 +25,12 @@ Centive batcher and the host callback).
25
25
  `stats()` as `context_received` / `context_delivered`. Anam has no
26
26
  server-side way to inform a running session, so this is the only path.
27
27
  - `AsyncSessions.send_events(ProductEventBatchRequest)`.
28
+ - **The session request carries the current page.** `TriggerSessionRequest.current_page`
29
+ (a `TelemetryPage`). The websocket server fills it from the `page` the browser
30
+ SDK puts in its `user_trigger` frame, or, for older browser SDKs, from the last
31
+ `page_view` seen on that connection. Centive builds the opening prompt from it:
32
+ measured on dev, trigger-session landed 1.3 s before the batched page_view, so
33
+ the assistant used to open on the page the previous visit had ended on.
28
34
  - Models: `TelemetryEvent`, `TelemetryFrame`, `TelemetryUserBatch`,
29
35
  `ProductEventBatchRequest`, `ProductEventIngestResponse`.
30
36
  - Config: `events_path`, `telemetry_enabled`, `telemetry_flush_max_events`,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: centive-sdk
3
- Version: 2.2.0.dev5
3
+ Version: 2.2.0.dev6
4
4
  Summary: Python SDK for Centive backend integration
5
5
  Project-URL: Homepage, https://github.com/TheAgenticAI/centive-backend-sdk
6
6
  Project-URL: Repository, https://github.com/TheAgenticAI/centive-backend-sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "centive-sdk"
7
- version = "2.2.0.dev5"
7
+ version = "2.2.0.dev6"
8
8
  description = "Python SDK for Centive backend integration"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -20,6 +20,14 @@ class TriggerSessionRequest(BaseModel):
20
20
  user_trigger: Optional[bool] = Field(
21
21
  None, description="optional trigger to force session creation"
22
22
  )
23
+ # The page the browser has open at the moment of the request. Centive builds
24
+ # the opening prompt from it instead of from stored page views, which travel
25
+ # through the telemetry batcher and were measured arriving 1.3 s after this
26
+ # call. Optional: an older browser SDK sends none, and the websocket server
27
+ # then falls back to the last page_view it saw on the connection.
28
+ current_page: Optional["TelemetryPage"] = Field(
29
+ None, description="page the browser reports having open when it asks for a session"
30
+ )
23
31
 
24
32
 
25
33
  class SessionMessage(BaseModel):
@@ -107,6 +115,9 @@ class TelemetryPage(BaseModel):
107
115
  referrer_path: Optional[str] = Field(default=None, max_length=TELEMETRY_MAX_PATH_CHARS)
108
116
 
109
117
 
118
+ TriggerSessionRequest.model_rebuild()
119
+
120
+
110
121
  class TelemetryEvent(BaseModel):
111
122
  """one C1 event as produced by the browser SDK; forwarded to Centive unchanged."""
112
123
 
@@ -17,6 +17,7 @@ from ..models.requests import (
17
17
  SessionMessage,
18
18
  SessionMetadata,
19
19
  TelemetryEvent,
20
+ TelemetryPage,
20
21
  TriggerSessionRequest,
21
22
  )
22
23
 
@@ -77,6 +78,10 @@ class WebSocketServer:
77
78
  self._pending_users: Deque[str] = deque() # legacy "open" mode only
78
79
  self._connection_tokens: Dict[str, Tuple[str, float]] = {} # token -> (user_id, expiry)
79
80
  self._connection_users: Dict[WebSocketServerProtocol, str] = {}
81
+ # Last page_view seen on each connection, forwarded as trigger-session's
82
+ # current_page when the FE's user_trigger frame carries no page of its
83
+ # own (older browser SDKs). Bounded by the number of live connections.
84
+ self._connection_last_page: Dict[WebSocketServerProtocol, dict] = {}
80
85
  # Every session key touched by a connection, so a disconnect saves all of
81
86
  # them rather than only the most recent one.
82
87
  self._connection_sessions: Dict[WebSocketServerProtocol, Set[str]] = {}
@@ -340,6 +345,7 @@ class WebSocketServer:
340
345
  stale = [ws for ws in self._connection_users if not self._is_live(ws)]
341
346
  for ws in stale:
342
347
  self._connection_users.pop(ws, None)
348
+ self._connection_last_page.pop(ws, None)
343
349
  self._clients.discard(ws)
344
350
 
345
351
  return sum(1 for bound in self._connection_users.values() if bound == user_id)
@@ -592,6 +598,7 @@ class WebSocketServer:
592
598
  # immediately after a drop).
593
599
  self._clients.discard(websocket)
594
600
  self._connection_users.pop(websocket, None)
601
+ self._connection_last_page.pop(websocket, None)
595
602
  self._telemetry_rate.forget(websocket)
596
603
  session_keys = self._connection_sessions.pop(websocket, set())
597
604
 
@@ -1033,6 +1040,8 @@ class WebSocketServer:
1033
1040
  }))
1034
1041
  return
1035
1042
 
1043
+ self._remember_page(websocket, data["events"])
1044
+
1036
1045
  batcher_active = self._telemetry is not None and self._config.telemetry_enabled
1037
1046
  enqueued = 0
1038
1047
  queue_dropped = 0
@@ -1394,7 +1403,38 @@ class WebSocketServer:
1394
1403
  return
1395
1404
 
1396
1405
  user_trigger = data.get("user_trigger")
1397
- await self._trigger_and_emit(websocket, user_id, user_trigger, client_info)
1406
+ # The page the browser has open right now, so Centive's opening prompt
1407
+ # starts from it rather than from a page_view still waiting in the
1408
+ # batcher. The frame's own page wins; the last page_view seen on this
1409
+ # connection stands in for browser SDKs that predate the field.
1410
+ current_page = self._coerce_page(data.get("page")) or self._connection_last_page.get(websocket)
1411
+ await self._trigger_and_emit(
1412
+ websocket, user_id, user_trigger, client_info, current_page=current_page
1413
+ )
1414
+
1415
+ def _remember_page(self, websocket: WebSocketServerProtocol, events: Any) -> None:
1416
+ """Keeps the newest page_view's page for this connection (see _handle_user_trigger)."""
1417
+ if not isinstance(events, list):
1418
+ return
1419
+ for event in reversed(events):
1420
+ if isinstance(event, dict) and event.get("event_type") == "page_view":
1421
+ page = self._coerce_page(event.get("page"))
1422
+ if page is not None:
1423
+ self._connection_last_page[websocket] = page
1424
+ return
1425
+
1426
+ @staticmethod
1427
+ def _coerce_page(value: Any) -> Optional[dict]:
1428
+ """A C1 page object reduced to the fields Centive accepts, or None.
1429
+
1430
+ Never raises: a malformed hint must not cost the user their session.
1431
+ """
1432
+ if not isinstance(value, dict):
1433
+ return None
1434
+ try:
1435
+ return TelemetryPage.model_validate(value).model_dump(exclude_none=True)
1436
+ except Exception:
1437
+ return None
1398
1438
 
1399
1439
  async def _trigger_and_emit(
1400
1440
  self,
@@ -1402,19 +1442,22 @@ class WebSocketServer:
1402
1442
  user_id: str,
1403
1443
  user_trigger: Optional[bool],
1404
1444
  client_info: str,
1445
+ current_page: Optional[dict] = None,
1405
1446
  ) -> None:
1406
1447
  """calls trigger_session API and emits response to FE."""
1407
1448
  safe_log(
1408
1449
  self._logger,
1409
1450
  "info",
1410
1451
  "triggering session",
1411
- {"client": client_info, "user_id": user_id, "user_trigger": user_trigger},
1452
+ {"client": client_info, "user_id": user_id, "user_trigger": user_trigger,
1453
+ "has_page": current_page is not None},
1412
1454
  )
1413
1455
 
1414
1456
  try:
1415
1457
  request = TriggerSessionRequest(
1416
1458
  user_id=user_id,
1417
1459
  user_trigger=user_trigger,
1460
+ current_page=current_page,
1418
1461
  )
1419
1462
 
1420
1463
  response = await self._sessions.trigger_session(request)
@@ -109,6 +109,90 @@ async def test_user_trigger_message(base_url, api_key, ws_port, trigger_session_
109
109
  await client.aclose()
110
110
 
111
111
 
112
+ @pytest.mark.asyncio
113
+ @respx.mock
114
+ async def test_user_trigger_forwards_the_browsers_page(base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response):
115
+ """The page in the FE's user_trigger frame reaches trigger-session as current_page.
116
+
117
+ Measured on dev: trigger-session landed 1.3 s before the batched page_view,
118
+ so Centive built the opening prompt from the previous visit's last page.
119
+ """
120
+ respx.get(f"{base_url}/anam/pause-status/user_123").mock(
121
+ return_value=httpx.Response(200, json=pause_status_not_paused_response)
122
+ )
123
+ route = respx.post(f"{base_url}/anam/trigger-session").mock(
124
+ return_value=httpx.Response(200, json=trigger_session_response)
125
+ )
126
+
127
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
128
+ token = await client.initialize_websocket(user_id="user_123", port=ws_port)
129
+
130
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
131
+ await ws.recv() # auto-trigger on connect: no page known yet
132
+ assert "current_page" not in json.loads(route.calls.last.request.content)
133
+
134
+ await ws.send(json.dumps({
135
+ "user_trigger": True,
136
+ "page": {"path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices",
137
+ "referrer_path": "/home", "junk": "ignored"},
138
+ }))
139
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
140
+ assert data["status"] == "success"
141
+
142
+ body = json.loads(route.calls.last.request.content)
143
+ assert body["external_user_id"] == "user_123"
144
+ assert body["current_page"] == {
145
+ "path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices", "referrer_path": "/home",
146
+ }
147
+
148
+ # A malformed hint costs nothing: the session is still triggered, without it.
149
+ await ws.send(json.dumps({"user_trigger": True, "page": {"pattern": 7}}))
150
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
151
+ assert data["status"] == "success"
152
+ assert "current_page" not in json.loads(route.calls.last.request.content)
153
+
154
+ await client.aclose()
155
+
156
+
157
+ @pytest.mark.asyncio
158
+ @respx.mock
159
+ async def test_user_trigger_falls_back_to_the_last_page_view_seen(
160
+ base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response, telemetry_event_data
161
+ ):
162
+ """A browser SDK that predates the `page` field still gets the right opening
163
+ page: the last page_view on the connection stands in."""
164
+ respx.get(f"{base_url}/anam/pause-status/user_123").mock(
165
+ return_value=httpx.Response(200, json=pause_status_not_paused_response)
166
+ )
167
+ route = respx.post(f"{base_url}/anam/trigger-session").mock(
168
+ return_value=httpx.Response(200, json=trigger_session_response)
169
+ )
170
+ respx.post(f"{base_url}/anam/events").mock(
171
+ return_value=httpx.Response(202, json={"accepted": 2, "rejected": 0, "rejections": [], "context": []})
172
+ )
173
+
174
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
175
+ token = await client.initialize_websocket(user_id="user_123", port=ws_port)
176
+
177
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
178
+ await ws.recv()
179
+ frame = dict(telemetry_event_data)
180
+ await ws.send(json.dumps(frame))
181
+ ack = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
182
+ assert ack["type"] == "telemetry_ack"
183
+
184
+ await ws.send(json.dumps({"user_trigger": True}))
185
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
186
+ assert data["status"] == "success"
187
+
188
+ body = json.loads(route.calls.last.request.content)
189
+ latest_view = [e for e in frame["events"] if e["event_type"] == "page_view"][-1]["page"]
190
+ assert body["current_page"]["path"] == latest_view["path"]
191
+ assert body["current_page"]["pattern"] == latest_view["pattern"]
192
+
193
+ await client.aclose()
194
+
195
+
112
196
  @pytest.mark.asyncio
113
197
  @respx.mock
114
198
  async def test_connection_without_token_rejected(base_url, api_key, ws_port):