centive-sdk 2.2.0.dev8__py3-none-any.whl → 2.2.0.dev9__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.
centive_sdk/config.py CHANGED
@@ -39,6 +39,7 @@ class ClientConfig(BaseModel):
39
39
  save_messages_path: str = "/anam/save-messages"
40
40
  pause_status_path: str = "/anam/pause-status"
41
41
  events_path: str = "/anam/events"
42
+ kb_search_path: str = "/anam/kb-search"
42
43
  timeout_seconds: float = 10.0
43
44
  max_retries: int = 3
44
45
  initial_retry_delay: float = 0.5
@@ -1,4 +1,6 @@
1
1
  from .requests import (
2
+ KbSearchFrame,
3
+ KbSearchRequest,
2
4
  ProductEventBatchRequest,
3
5
  TelemetryEvent,
4
6
  TelemetryFrame,
@@ -7,6 +9,7 @@ from .requests import (
7
9
  TriggerSessionRequest,
8
10
  )
9
11
  from .responses import (
12
+ KbSearchResponse,
10
13
  PauseStatusResponse,
11
14
  ProductEventIngestResponse,
12
15
  ToolMappingResponse,
@@ -14,6 +17,9 @@ from .responses import (
14
17
  )
15
18
 
16
19
  __all__ = [
20
+ "KbSearchRequest",
21
+ "KbSearchFrame",
22
+ "KbSearchResponse",
17
23
  "ToolMappingRequest",
18
24
  "TriggerSessionRequest",
19
25
  "TelemetryEvent",
@@ -1,3 +1,4 @@
1
+ import re
1
2
  from typing import Any, Dict, List, Literal, Optional
2
3
 
3
4
  from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -28,6 +29,23 @@ class TriggerSessionRequest(BaseModel):
28
29
  current_page: Optional["TelemetryPage"] = Field(
29
30
  None, description="page the browser reports having open when it asks for a session"
30
31
  )
32
+ # What the browser SDK can serve in this session ("kb_search" for Aria's awaited
33
+ # knowledge-search tool). Centive attaches that tool only to sessions that
34
+ # declare it, so a customer on an older frontend never gets a tool nobody
35
+ # answers. Omitted when the browser declared nothing.
36
+ capabilities: Optional[List[str]] = Field(
37
+ None, max_length=16, description="capabilities the browser SDK declared for this session"
38
+ )
39
+
40
+ @field_validator("capabilities")
41
+ @classmethod
42
+ def _capability_tokens(cls, capabilities: Optional[List[str]]) -> Optional[List[str]]:
43
+ if capabilities is None:
44
+ return None
45
+ for capability in capabilities:
46
+ if not re.fullmatch(r"[a-z0-9_]{1,32}", capability):
47
+ raise ValueError("capabilities must be lowercase tokens of at most 32 characters")
48
+ return list(dict.fromkeys(capabilities))
31
49
 
32
50
 
33
51
  class SessionMessage(BaseModel):
@@ -96,6 +114,33 @@ class SessionEndEvent(BaseModel):
96
114
  user_id: str = Field(..., description="user ID")
97
115
 
98
116
 
117
+ class KbSearchRequest(BaseModel):
118
+ """request payload for POST /anam/kb-search: grounded evidence for Aria's client search tool."""
119
+
120
+ query: str = Field(..., min_length=1, max_length=4000, description="the user's sentence or the model's query")
121
+ alternate_query: Optional[str] = Field(None, max_length=4000, description="optional second phrasing")
122
+ user_messages: List[str] = Field(default_factory=list, max_length=10, description="the user's turns so far")
123
+ session_id: Optional[str] = Field(None, max_length=255, description="Anam session id, so unanswered questions tie to the conversation")
124
+ external_user_id: Optional[str] = Field(None, max_length=255, description="end-user id, for the knowledge-gap log")
125
+
126
+ @field_validator("user_messages")
127
+ @classmethod
128
+ def _bounded_user_messages(cls, messages: List[str]) -> List[str]:
129
+ for message in messages:
130
+ if len(message) > 4000:
131
+ raise ValueError("user message longer than 4000 characters")
132
+ return messages
133
+
134
+
135
+ class KbSearchFrame(KbSearchRequest):
136
+ """kb_search frame from FE SDK; answered with a kb_search_response frame carrying request_id."""
137
+
138
+ type: Literal["kb_search"] = "kb_search"
139
+ request_id: str = Field(..., min_length=1, max_length=128, description="client-chosen id echoed in the response")
140
+ session_id: Optional[str] = Field(None, description="Anam session id when known")
141
+ user_id: Optional[str] = Field(None, description="user id (checked against the connection identity)")
142
+
143
+
99
144
  # ---------------------------------------------------------------------------
100
145
  # Product events (page tracking). Contract: CentiveAI docs/product-events/CONTRACTS.md
101
146
  # ---------------------------------------------------------------------------
@@ -72,3 +72,18 @@ class ProductEventIngestResponse(BaseModel):
72
72
  # {"external_user_id", "content", "expires_at"}.
73
73
  context: list = Field(default_factory=list)
74
74
  raw: Optional[Dict[str, Any]] = None
75
+
76
+
77
+ class KbSearchResponse(BaseModel):
78
+ """evidence packet from POST /anam/kb-search (see CentiveAI kb_grounded_search_service)."""
79
+
80
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
81
+
82
+ status: str
83
+ document_passages: list = Field(default_factory=list)
84
+ user_messages: list = Field(default_factory=list)
85
+ answer_rules: str = ""
86
+ source_policy: str = ""
87
+ queries: list = Field(default_factory=list)
88
+ no_evidence_policy: Optional[str] = None
89
+ raw: Optional[Dict[str, Any]] = None
@@ -9,12 +9,14 @@ from .._retry import calculate_backoff, extract_retry_after, should_retry
9
9
  from ..config import ClientConfig
10
10
  from ..exceptions import AuthError, NetworkError, RateLimitError, ServerError, ValidationError
11
11
  from ..models.requests import (
12
+ KbSearchRequest,
12
13
  ProductEventBatchRequest,
13
14
  SaveMessagesRequest,
14
15
  ToolMappingRequest,
15
16
  TriggerSessionRequest,
16
17
  )
17
18
  from ..models.responses import (
19
+ KbSearchResponse,
18
20
  PauseStatusResponse,
19
21
  ProductEventIngestResponse,
20
22
  SaveMessagesResponse,
@@ -197,6 +199,23 @@ class AsyncSessions:
197
199
  raw=data,
198
200
  )
199
201
 
202
+ async def kb_search(self, input: KbSearchRequest) -> KbSearchResponse:
203
+ """Fetches a grounded evidence packet for Aria's client search tool.
204
+
205
+ Centive searches the organization's own knowledge folders with several
206
+ phrasings and validates provenance server-side. Raises the usual SDK
207
+ errors; the caller reports failure to the persona rather than guessing.
208
+ """
209
+ url = join_url(self._config.base_url, self._config.kb_search_path)
210
+ headers = build_headers(self._config.api_key)
211
+ # One attempt, no retries: the browser stops waiting for this answer after
212
+ # 12 s and Anam's tool times out at 15 s, while Centive already spends up
213
+ # to 8 s searching. A retried 503 would arrive after both have given up.
214
+ # Chain: Centive 8 s < this timeout (10 s, one attempt) < frontend 12 s < Anam 15 s.
215
+ response = await self._make_request(url, headers, input.model_dump(exclude_none=True), max_retries=0)
216
+ data = response.json()
217
+ return KbSearchResponse(**{key: value for key, value in data.items() if key != "raw"}, raw=data)
218
+
200
219
  async def send_events(self, input: ProductEventBatchRequest) -> ProductEventIngestResponse:
201
220
  """Sends a batch of product events (page views) to Centive.
202
221
 
@@ -394,12 +413,17 @@ class AsyncSessions:
394
413
  ) from last_exception
395
414
 
396
415
  async def _make_request(
397
- self, url: str, headers: dict, payload: dict
416
+ self, url: str, headers: dict, payload: dict, max_retries: Optional[int] = None
398
417
  ) -> httpx.Response:
399
- """Makes an HTTP POST request with retry logic and error handling."""
418
+ """Makes an HTTP POST request with retry logic and error handling.
419
+
420
+ ``max_retries`` overrides the configured retry count for calls whose
421
+ caller has its own deadline (``kb_search`` passes 0).
422
+ """
400
423
  last_exception = None
424
+ retries = self._config.max_retries if max_retries is None else max_retries
401
425
 
402
- for attempt in range(self._config.max_retries + 1):
426
+ for attempt in range(retries + 1):
403
427
  try:
404
428
  response = await self._client.post(
405
429
  url,
@@ -440,7 +464,7 @@ class AsyncSessions:
440
464
  elif response.status_code == 429:
441
465
  retry_after = extract_retry_after(response.headers)
442
466
 
443
- if should_retry(429, attempt, self._config.max_retries):
467
+ if should_retry(429, attempt, retries):
444
468
  delay = (
445
469
  retry_after
446
470
  if retry_after is not None
@@ -467,7 +491,7 @@ class AsyncSessions:
467
491
  )
468
492
 
469
493
  elif response.status_code >= 500:
470
- if should_retry(response.status_code, attempt, self._config.max_retries):
494
+ if should_retry(response.status_code, attempt, retries):
471
495
  delay = calculate_backoff(
472
496
  attempt, self._config.initial_retry_delay, self._config.max_retry_delay
473
497
  )
@@ -503,7 +527,7 @@ class AsyncSessions:
503
527
  except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError) as e:
504
528
  last_exception = e
505
529
 
506
- if attempt < self._config.max_retries:
530
+ if attempt < retries:
507
531
  delay = calculate_backoff(
508
532
  attempt, self._config.initial_retry_delay, self._config.max_retry_delay
509
533
  )
@@ -517,7 +541,7 @@ class AsyncSessions:
517
541
  continue
518
542
 
519
543
  raise NetworkError(
520
- f"Network error after {self._config.max_retries} retries: {str(last_exception)}"
544
+ f"Network error after {retries} retries: {str(last_exception)}"
521
545
  ) from last_exception
522
546
 
523
547
  def _extract_error_message(self, response: httpx.Response) -> str:
@@ -1,11 +1,12 @@
1
1
  import asyncio
2
2
  import inspect
3
3
  import json
4
+ import re
4
5
  import secrets
5
6
  import time
6
7
  import warnings
7
8
  from collections import deque
8
- from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, Optional, Set, Tuple
9
+ from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, List, Optional, Set, Tuple
9
10
  from urllib.parse import parse_qs, urlparse
10
11
 
11
12
  import websockets
@@ -13,7 +14,10 @@ from websockets.server import WebSocketServerProtocol
13
14
 
14
15
  from .._logging import safe_log, transport_logger
15
16
  from ..config import ClientConfig
17
+ from ..exceptions import CentiveError
16
18
  from ..models.requests import (
19
+ KbSearchFrame,
20
+ KbSearchRequest,
17
21
  SaveMessagesRequest,
18
22
  SessionMessage,
19
23
  SessionMetadata,
@@ -83,6 +87,10 @@ class WebSocketServer:
83
87
  # current_page when the FE's user_trigger frame carries no page of its
84
88
  # own (older browser SDKs). Bounded by the number of live connections.
85
89
  self._connection_last_page: Dict[WebSocketServerProtocol, dict] = {}
90
+ # What each browser declared it can serve (e.g. "kb_search"), taken from its
91
+ # user_trigger frame and forwarded on every trigger-session call for the
92
+ # connection, so Centive attaches Aria's client tool only where it works.
93
+ self._connection_capabilities: Dict[WebSocketServerProtocol, List[str]] = {}
86
94
  # One Centive trigger-session call per connection start, not two: the
87
95
  # connect-time automatic trigger is held back for a grace period and
88
96
  # stands down when the client asked itself (_auto_trigger_after_grace);
@@ -610,6 +618,7 @@ class WebSocketServer:
610
618
  self._clients.discard(websocket)
611
619
  self._connection_users.pop(websocket, None)
612
620
  self._connection_last_page.pop(websocket, None)
621
+ self._connection_capabilities.pop(websocket, None)
613
622
  self._connection_triggered.discard(websocket)
614
623
  self._connection_trigger_inflight.pop(websocket, None)
615
624
  pending_trigger = self._connection_trigger_tasks.pop(websocket, None)
@@ -669,6 +678,10 @@ class WebSocketServer:
669
678
  await self._handle_session_end(websocket, data, client_info)
670
679
  return
671
680
 
681
+ if msg_type == "kb_search":
682
+ await self._handle_kb_search(websocket, data, client_info)
683
+ return
684
+
672
685
  if msg_type == "telemetry":
673
686
  # The raw message goes along: the frame byte cap is on what was
674
687
  # actually sent, and re-serializing `data` would not reproduce it.
@@ -684,6 +697,81 @@ class WebSocketServer:
684
697
  "message": "message received",
685
698
  }))
686
699
 
700
+ async def _handle_kb_search(
701
+ self,
702
+ websocket: WebSocketServerProtocol,
703
+ data: dict,
704
+ client_info: str,
705
+ ) -> None:
706
+ """serves Aria's awaited knowledge-search tool.
707
+
708
+ The FE SDK sends the user's sentence (prefetch) or the model's own query;
709
+ Centive answers with an evidence packet scoped to this organization. A
710
+ failed lookup is reported as an error frame so the persona says that
711
+ verification failed instead of answering from nothing.
712
+ """
713
+ request_id = data.get("request_id") if isinstance(data.get("request_id"), str) else None
714
+ try:
715
+ frame = KbSearchFrame.model_validate(data)
716
+ except Exception as e: # pydantic ValidationError or wrong shape
717
+ await websocket.send(json.dumps({
718
+ "type": "kb_search_response",
719
+ "request_id": request_id,
720
+ "status": "error",
721
+ "error": "INVALID_KB_SEARCH",
722
+ "message": f"invalid kb_search frame: {str(e)[:200]}",
723
+ }))
724
+ return
725
+
726
+ if self._config.ws_auth_mode != "open":
727
+ if await self._enforce_frame_identity(websocket, data, frame.session_id, client_info) is None:
728
+ return
729
+
730
+ request = KbSearchRequest(
731
+ query=frame.query,
732
+ alternate_query=frame.alternate_query,
733
+ user_messages=frame.user_messages,
734
+ session_id=frame.session_id,
735
+ external_user_id=frame.user_id,
736
+ )
737
+ started = time.monotonic()
738
+ try:
739
+ response = await self._sessions.kb_search(request)
740
+ except CentiveError as e:
741
+ safe_log(
742
+ self._logger,
743
+ "warning",
744
+ "kb_search failed",
745
+ {"client": client_info, "request_id": request_id, "error": str(e)[:200]},
746
+ )
747
+ await websocket.send(json.dumps({
748
+ "type": "kb_search_response",
749
+ "request_id": frame.request_id,
750
+ "status": "error",
751
+ "error": "KB_SEARCH_UNAVAILABLE",
752
+ "message": "knowledge search is unavailable",
753
+ }))
754
+ return
755
+
756
+ packet = response.raw if response.raw is not None else response.model_dump(exclude={"raw"})
757
+ safe_log(
758
+ self._logger,
759
+ "info",
760
+ "kb_search served",
761
+ {
762
+ "client": client_info,
763
+ "request_id": frame.request_id,
764
+ "passages": len(packet.get("document_passages", [])),
765
+ "seconds": round(time.monotonic() - started, 3),
766
+ },
767
+ )
768
+ await websocket.send(json.dumps({
769
+ "type": "kb_search_response",
770
+ "request_id": frame.request_id,
771
+ "status": "success",
772
+ "packet": packet,
773
+ }))
774
+
687
775
  async def _enforce_frame_identity(
688
776
  self,
689
777
  websocket: WebSocketServerProtocol,
@@ -1424,6 +1512,9 @@ class WebSocketServer:
1424
1512
  # batcher. The frame's own page wins; the last page_view seen on this
1425
1513
  # connection stands in for browser SDKs that predate the field.
1426
1514
  current_page = self._coerce_page(data.get("page")) or self._connection_last_page.get(websocket)
1515
+ declared = self._coerce_capabilities(data.get("capabilities"))
1516
+ if declared:
1517
+ self._connection_capabilities[websocket] = declared
1427
1518
  # The client asked itself, so the connect-time automatic trigger, if it
1428
1519
  # is still waiting out its grace period, has nothing left to do.
1429
1520
  self._connection_triggered.add(websocket)
@@ -1431,6 +1522,17 @@ class WebSocketServer:
1431
1522
  websocket, user_id, user_trigger, client_info, current_page=current_page
1432
1523
  )
1433
1524
 
1525
+ @staticmethod
1526
+ def _coerce_capabilities(value: object) -> Optional[List[str]]:
1527
+ """Bounded list of capability tokens from a frame, or None when nothing usable was declared."""
1528
+ if not isinstance(value, list):
1529
+ return None
1530
+ tokens: List[str] = []
1531
+ for item in value[:16]:
1532
+ if isinstance(item, str) and re.fullmatch(r"[a-z0-9_]{1,32}", item) and item not in tokens:
1533
+ tokens.append(item)
1534
+ return tokens or None
1535
+
1434
1536
  async def _auto_trigger_after_grace(
1435
1537
  self, websocket: WebSocketServerProtocol, user_id: str, client_info: str
1436
1538
  ) -> None:
@@ -1554,6 +1656,7 @@ class WebSocketServer:
1554
1656
  user_id=user_id,
1555
1657
  user_trigger=user_trigger,
1556
1658
  current_page=current_page,
1659
+ capabilities=self._connection_capabilities.get(websocket),
1557
1660
  )
1558
1661
 
1559
1662
  response = await self._sessions.trigger_session(request)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: centive-sdk
3
- Version: 2.2.0.dev8
3
+ Version: 2.2.0.dev9
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,20 +4,20 @@ centive_sdk/_logging.py,sha256=z8Kdi4vJdko0nkMeySGSHwPkEB2E7ucklc9FeJCbyOw,3518
4
4
  centive_sdk/_retry.py,sha256=Q1cIFZyimj0RS4x14FEOseqpORCSydvJyzGiIPfDn7E,1755
5
5
  centive_sdk/async_client.py,sha256=RRoCJrvdeLwQz5UJv2EjmlgTq7vntSV9ELMWHyNwJ5I,9355
6
6
  centive_sdk/client.py,sha256=Evtsng8cqqRSoV54MI2IKbEW1q0vPOkxlz9zM0evH7k,3249
7
- centive_sdk/config.py,sha256=1GoHkwJebL1HknSa-y4oFhxDAaG0boq0JY0csNV-pfE,6612
7
+ centive_sdk/config.py,sha256=V1ZZN6_oZyilR-JUBglT776AcIy4hZQvLBnH7gpvN-o,6656
8
8
  centive_sdk/exceptions.py,sha256=wFy-hU0TJ0dHa6TEDI7FIQu9Lbg5aMeWxW9E-IMMUbI,1841
9
9
  centive_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- centive_sdk/models/__init__.py,sha256=-ledNpbOi1Q14ELhvlDbGPF37N3yv5XJ-QsH2I6UMVQ,598
11
- centive_sdk/models/requests.py,sha256=_SqXf-8wejbAVQOMf6Lk3W0q3F3yu401mFvB8dMhwp4,7736
12
- centive_sdk/models/responses.py,sha256=zCYPB0aL2DXn-z0Febkuxo3DdZ_5sdpdGy2Jtf8Ubs8,2519
10
+ centive_sdk/models/__init__.py,sha256=kvQ8WmLSdbLHOlJ95H7ZSm4Fe_E9NN7LK34BHl4_Z_4,728
11
+ centive_sdk/models/requests.py,sha256=O1MnGWNj7-vdvu95Km1WrK7Z0TF6KHTeBZO1I9tCChc,10179
12
+ centive_sdk/models/responses.py,sha256=T8xpw6N6Ap-UplXyBCxCtdXbBV0H8UjV9ZdxmxhXOAs,3039
13
13
  centive_sdk/resources/__init__.py,sha256=GLutz38X4F4FwPfSZy35EkWTV7l7t9RgcBKwXmz2X_Y,179
14
- centive_sdk/resources/async_sessions.py,sha256=m0_gj2wLabs3BTe6rZuo9WGgSwEMJLmdKW8_s4T7KRM,21500
14
+ centive_sdk/resources/async_sessions.py,sha256=bdscJFj_nlqe0FZD3rXjai4IwlfH5I6VFFk4kXIzRwI,22841
15
15
  centive_sdk/resources/async_telemetry.py,sha256=wT12g9rG2dp_ml5YpAYCxobLlhfcsyy-LHty4bwwFZI,2431
16
16
  centive_sdk/resources/message_accumulator.py,sha256=Or3sMGkXG9upeQ9sm8YWL_V5vvsxtnE9tu52VBUUGgY,19261
17
17
  centive_sdk/resources/sessions.py,sha256=Ipg4bpmpejA5WoALbT49ah0c3I7x4OgPn9tSmfW8JAw,16741
18
18
  centive_sdk/resources/telemetry_batcher.py,sha256=OkOv2Iir6E_9mdtAKvelol3RaveGSlH-ckIoE2cQYCc,14629
19
- centive_sdk/resources/websocket_server.py,sha256=RTkM1YyD7afNWs4KpoboCo-NI2TFgk8IkzaH0KAKR5A,66518
20
- centive_sdk-2.2.0.dev8.dist-info/METADATA,sha256=5DM9IFShg7CIqzt5jQ8goRocZXmOCJtELTUWVUgVSZY,36427
21
- centive_sdk-2.2.0.dev8.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
22
- centive_sdk-2.2.0.dev8.dist-info/licenses/LICENSE,sha256=9dFibPr7z9srhWwd35lQL2MX1Kx2qivEZdVQb9L8Q-Q,1064
23
- centive_sdk-2.2.0.dev8.dist-info/RECORD,,
19
+ centive_sdk/resources/websocket_server.py,sha256=_U_PPeB-_fLyxQVPC0SlV5V0AD8ECULYdbeCkLLSyy0,70723
20
+ centive_sdk-2.2.0.dev9.dist-info/METADATA,sha256=cDNuDwvyAHMVPJML42egvWLSwOUyCvPsOYz_pMMKj6o,36427
21
+ centive_sdk-2.2.0.dev9.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
22
+ centive_sdk-2.2.0.dev9.dist-info/licenses/LICENSE,sha256=9dFibPr7z9srhWwd35lQL2MX1Kx2qivEZdVQb9L8Q-Q,1064
23
+ centive_sdk-2.2.0.dev9.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.0
2
+ Generator: hatchling 1.32.3
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any