centive-sdk 2.2.0.dev8__tar.gz → 2.2.0.dev9__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.
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/CHANGELOG.md +31 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/PKG-INFO +1 -1
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/pyproject.toml +1 -1
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/config.py +1 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/models/__init__.py +6 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/models/requests.py +45 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/models/responses.py +15 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/async_sessions.py +31 -7
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/websocket_server.py +104 -1
- centive_sdk-2.2.0.dev9/tests/test_websocket_capabilities.py +80 -0
- centive_sdk-2.2.0.dev9/tests/test_websocket_kb_search.py +145 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/.gitignore +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/LICENSE +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/README.md +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/blackbox/README.md +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/blackbox/customer_app.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/blackbox/harness.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/blackbox/mock_centive_api.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/blackbox/test_blackbox.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/blackbox/test_blackbox_telemetry.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/examples/async_example.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/examples/fastapi_integration.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/examples/sync_example.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/examples/websocket_example.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/__init__.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/_http.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/_logging.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/_retry.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/async_client.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/client.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/exceptions.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/py.typed +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/__init__.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/async_telemetry.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/message_accumulator.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/sessions.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/telemetry_batcher.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/__init__.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/conftest.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/test_client_async.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/test_client_sync.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/test_config.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/test_error_mapping.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/test_logging_redaction.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/test_message_accumulator.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/test_retry_logic.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/test_telemetry_batcher.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/test_websocket_security.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/test_websocket_server.py +0 -0
- {centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/tests/test_websocket_telemetry.py +0 -0
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Grounded knowledge search for Aria's client tool.** A new websocket frame,
|
|
10
|
+
`type: "kb_search"` (`request_id`, `query`, optional `alternate_query`,
|
|
11
|
+
`user_messages`), is forwarded to Centive's `POST /anam/kb-search`, which
|
|
12
|
+
searches only this organization's knowledge folders with several phrasings
|
|
13
|
+
and returns an evidence packet. The server answers with
|
|
14
|
+
`type: "kb_search_response"` carrying the same `request_id` and either
|
|
15
|
+
`status: "success", packet` or `status: "error", error` (`INVALID_KB_SEARCH`,
|
|
16
|
+
`KB_SEARCH_UNAVAILABLE`). In token mode the frame is bound to the connection's
|
|
17
|
+
user like every other frame. A failed lookup is reported, never guessed.
|
|
18
|
+
- `AsyncSessions.kb_search(KbSearchRequest) -> KbSearchResponse` and
|
|
19
|
+
`ClientConfig.kb_search_path` (default `/anam/kb-search`).
|
|
20
|
+
- The `kb_search` frame's `session_id` and `user_id` are forwarded to Centive as
|
|
21
|
+
`session_id` / `external_user_id` on `KbSearchRequest`, so a question the
|
|
22
|
+
documentation could not answer is logged against the conversation in the
|
|
23
|
+
organization's knowledge-gap log.
|
|
24
|
+
- The browser's declared `capabilities` (e.g. `["kb_search"]`) on the `user_trigger`
|
|
25
|
+
frame are validated, remembered for the connection and forwarded on every
|
|
26
|
+
trigger-session call as `TriggerSessionRequest.capabilities`, so Centive attaches
|
|
27
|
+
Aria's client search tool only to sessions whose frontend can serve it.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `AsyncSessions.kb_search` makes a single attempt (no retries). Centive spends up to
|
|
32
|
+
8 s searching, the browser stops waiting at 12 s and Anam's tool times out at 15 s,
|
|
33
|
+
so a retried 503 could only arrive after everyone had given up. `_make_request`
|
|
34
|
+
accepts a per-call `max_retries` for this.
|
|
35
|
+
|
|
5
36
|
## v2.2.0
|
|
6
37
|
|
|
7
38
|
Unreleased (branch `feature/user-page-tracking`, merged with the v2.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: centive-sdk
|
|
3
|
-
Version: 2.2.0.
|
|
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
|
|
@@ -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
|
{centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/async_sessions.py
RENAMED
|
@@ -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(
|
|
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,
|
|
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,
|
|
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 <
|
|
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 {
|
|
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:
|
{centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/websocket_server.py
RENAMED
|
@@ -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)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The browser's declared capabilities ride the user_trigger frame and are forwarded on every
|
|
2
|
+
trigger-session call for that connection; malformed tokens are dropped, nothing declared means the
|
|
3
|
+
field is absent so Centive keeps Anam's built-in tools."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
import respx
|
|
11
|
+
import websockets
|
|
12
|
+
|
|
13
|
+
from centive_sdk import AsyncCentiveClient
|
|
14
|
+
|
|
15
|
+
WS_PORT = 18791
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def trigger_session_response():
|
|
20
|
+
return {
|
|
21
|
+
"status": "success",
|
|
22
|
+
"message": "Session created successfully",
|
|
23
|
+
"session_data": {"session_id": "sess_123", "token": "tok_abc"},
|
|
24
|
+
"time_taken": 0.1,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _connect(client, base_url, trigger_session_response, pause_status_not_paused_response):
|
|
29
|
+
respx.get(f"{base_url}/anam/pause-status/user_123").mock(return_value=httpx.Response(200, json=pause_status_not_paused_response))
|
|
30
|
+
route = respx.post(f"{base_url}/anam/trigger-session").mock(return_value=httpx.Response(200, json=trigger_session_response))
|
|
31
|
+
token = await client.initialize_websocket(user_id="user_123", port=WS_PORT)
|
|
32
|
+
ws = await websockets.connect(f"ws://localhost:{WS_PORT}/ws?token={token}")
|
|
33
|
+
first = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
34
|
+
assert first["status"] == "success" # connect-time session frame
|
|
35
|
+
return ws, route
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _trigger(ws, frame):
|
|
39
|
+
await ws.send(json.dumps(frame))
|
|
40
|
+
for _ in range(5):
|
|
41
|
+
data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
42
|
+
if data.get("status") == "success" and "session_data" in data:
|
|
43
|
+
return data
|
|
44
|
+
raise AssertionError("no session frame received")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _last_body(route):
|
|
48
|
+
return json.loads(route.calls[-1].request.content)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
@respx.mock
|
|
53
|
+
async def test_declared_capabilities_are_forwarded_and_remembered(base_url, api_key, trigger_session_response, pause_status_not_paused_response):
|
|
54
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
|
|
55
|
+
ws, route = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
|
|
56
|
+
try:
|
|
57
|
+
assert "capabilities" not in _last_body(route) # the automatic connect-time trigger declared nothing
|
|
58
|
+
await _trigger(ws, {"user_trigger": True, "user_id": "user_123", "capabilities": ["kb_search", "Bad Token!", 42, "kb_search"]})
|
|
59
|
+
assert _last_body(route)["capabilities"] == ["kb_search"]
|
|
60
|
+
# A later trigger on the same connection (a token refresh) keeps the declaration.
|
|
61
|
+
await _trigger(ws, {"user_trigger": True, "user_id": "user_123"})
|
|
62
|
+
assert _last_body(route)["capabilities"] == ["kb_search"]
|
|
63
|
+
finally:
|
|
64
|
+
await ws.close()
|
|
65
|
+
await client.aclose()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
@respx.mock
|
|
70
|
+
async def test_nothing_usable_declared_means_no_capabilities_field(base_url, api_key, trigger_session_response, pause_status_not_paused_response):
|
|
71
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
|
|
72
|
+
ws, route = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
|
|
73
|
+
try:
|
|
74
|
+
await _trigger(ws, {"user_trigger": True, "user_id": "user_123", "capabilities": ["Not A Token"]})
|
|
75
|
+
assert "capabilities" not in _last_body(route)
|
|
76
|
+
await _trigger(ws, {"user_trigger": True, "user_id": "user_123", "capabilities": "kb_search"}) # wrong shape
|
|
77
|
+
assert "capabilities" not in _last_body(route)
|
|
78
|
+
finally:
|
|
79
|
+
await ws.close()
|
|
80
|
+
await client.aclose()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""kb_search frames: served from Centive's /anam/kb-search, scoped to the connection's user,
|
|
2
|
+
and reported as an error frame (never a guess) when the lookup fails."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
import respx
|
|
10
|
+
import websockets
|
|
11
|
+
|
|
12
|
+
from centive_sdk import AsyncCentiveClient
|
|
13
|
+
|
|
14
|
+
WS_PORT = 18790
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def trigger_session_response():
|
|
19
|
+
return {
|
|
20
|
+
"status": "success",
|
|
21
|
+
"message": "Session created successfully",
|
|
22
|
+
"session_data": {"session_id": "sess_123", "token": "tok_abc"},
|
|
23
|
+
"time_taken": 0.1,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ws_url(token):
|
|
28
|
+
return f"ws://localhost:{WS_PORT}/ws?token={token}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
PACKET = {
|
|
32
|
+
"status": "sources_found",
|
|
33
|
+
"document_passages": [{"document_id": "d1", "chunk_index": 0, "folder_id": "f", "filename": "faqs.md",
|
|
34
|
+
"content": "Invite them from Team, Invite Member.", "score": 0.7, "sha256": "h"}],
|
|
35
|
+
"user_messages": [{"turn_index": 0, "text": "how do I invite", "origin": "user_message", "verified_by_document": False}],
|
|
36
|
+
"answer_rules": "rules", "source_policy": "policy", "queries": ["how do I invite", "invite"],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _connect(client, base_url, trigger_session_response, pause_status_not_paused_response):
|
|
41
|
+
respx.get(f"{base_url}/anam/pause-status/user_123").mock(return_value=httpx.Response(200, json=pause_status_not_paused_response))
|
|
42
|
+
respx.post(f"{base_url}/anam/trigger-session").mock(return_value=httpx.Response(200, json=trigger_session_response))
|
|
43
|
+
token = await client.initialize_websocket(user_id="user_123", port=WS_PORT)
|
|
44
|
+
ws = await websockets.connect(ws_url(token))
|
|
45
|
+
first = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
46
|
+
assert first["status"] == "success" # connect-time session frame
|
|
47
|
+
return ws
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _recv_type(ws, frame_type):
|
|
51
|
+
for _ in range(5):
|
|
52
|
+
data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
53
|
+
if data.get("type") == frame_type:
|
|
54
|
+
return data
|
|
55
|
+
raise AssertionError(f"no {frame_type} frame received")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.asyncio
|
|
59
|
+
@respx.mock
|
|
60
|
+
async def test_kb_search_frame_is_served_from_centive(base_url, api_key, trigger_session_response, pause_status_not_paused_response):
|
|
61
|
+
route = respx.post(f"{base_url}/anam/kb-search").mock(return_value=httpx.Response(200, json=PACKET))
|
|
62
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
|
|
63
|
+
ws = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
|
|
64
|
+
try:
|
|
65
|
+
await ws.send(json.dumps({"type": "kb_search", "request_id": "r1", "user_id": "user_123", "session_id": "sess_123",
|
|
66
|
+
"query": "how do I invite", "alternate_query": "invite member", "user_messages": ["how do I invite"]}))
|
|
67
|
+
frame = await _recv_type(ws, "kb_search_response")
|
|
68
|
+
assert frame["status"] == "success" and frame["request_id"] == "r1"
|
|
69
|
+
assert frame["packet"]["document_passages"][0]["document_id"] == "d1"
|
|
70
|
+
sent = json.loads(route.calls[0].request.content)
|
|
71
|
+
assert sent == {"query": "how do I invite", "alternate_query": "invite member", "user_messages": ["how do I invite"],
|
|
72
|
+
"session_id": "sess_123", "external_user_id": "user_123"}
|
|
73
|
+
assert route.calls[0].request.headers["X-API-Key"] == api_key
|
|
74
|
+
finally:
|
|
75
|
+
await ws.close()
|
|
76
|
+
await client.aclose()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
@respx.mock
|
|
81
|
+
async def test_kb_search_failure_is_an_error_frame_after_a_single_attempt(base_url, api_key, trigger_session_response, pause_status_not_paused_response):
|
|
82
|
+
"""A 503 from Centive is not retried: the browser stops waiting at 12 s and Anam at 15 s,
|
|
83
|
+
so the error frame must arrive at once rather than after three back-offs."""
|
|
84
|
+
route = respx.post(f"{base_url}/anam/kb-search").mock(return_value=httpx.Response(503, json={"detail": "Knowledge search is unavailable"}))
|
|
85
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url) # default retry policy (3) must not apply here
|
|
86
|
+
ws = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
|
|
87
|
+
try:
|
|
88
|
+
await ws.send(json.dumps({"type": "kb_search", "request_id": "r2", "user_id": "user_123", "query": "anything"}))
|
|
89
|
+
frame = await asyncio.wait_for(_recv_type(ws, "kb_search_response"), timeout=3.0)
|
|
90
|
+
assert frame == {"type": "kb_search_response", "request_id": "r2", "status": "error",
|
|
91
|
+
"error": "KB_SEARCH_UNAVAILABLE", "message": "knowledge search is unavailable"}
|
|
92
|
+
assert route.call_count == 1
|
|
93
|
+
finally:
|
|
94
|
+
await ws.close()
|
|
95
|
+
await client.aclose()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.asyncio
|
|
99
|
+
@respx.mock
|
|
100
|
+
async def test_kb_search_rejects_bad_frames_and_foreign_identity(base_url, api_key, trigger_session_response, pause_status_not_paused_response):
|
|
101
|
+
route = respx.post(f"{base_url}/anam/kb-search").mock(return_value=httpx.Response(200, json=PACKET))
|
|
102
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
|
|
103
|
+
ws = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
|
|
104
|
+
try:
|
|
105
|
+
await ws.send(json.dumps({"type": "kb_search", "request_id": "r3", "query": ""}))
|
|
106
|
+
frame = await _recv_type(ws, "kb_search_response")
|
|
107
|
+
assert frame["status"] == "error" and frame["error"] == "INVALID_KB_SEARCH" and frame["request_id"] == "r3"
|
|
108
|
+
|
|
109
|
+
await ws.send(json.dumps({"type": "kb_search", "request_id": "r4", "user_id": "someone_else", "query": "ok"}))
|
|
110
|
+
data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
111
|
+
assert data["status"] == "error" and data["error"] == "IDENTITY_MISMATCH"
|
|
112
|
+
assert route.call_count == 0
|
|
113
|
+
finally:
|
|
114
|
+
await ws.close()
|
|
115
|
+
await client.aclose()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
@respx.mock
|
|
120
|
+
async def test_long_chat_message_and_followup_pass_through_without_truncation(
|
|
121
|
+
base_url, api_key, trigger_session_response, pause_status_not_paused_response,
|
|
122
|
+
):
|
|
123
|
+
route = respx.post(f"{base_url}/anam/kb-search").mock(return_value=httpx.Response(200, json=PACKET))
|
|
124
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
|
|
125
|
+
ws = await _connect(client, base_url, trigger_session_response, pause_status_not_paused_response)
|
|
126
|
+
question = "q" * 4000
|
|
127
|
+
history = [question]
|
|
128
|
+
try:
|
|
129
|
+
for index, query in enumerate([question, "download invoice"]):
|
|
130
|
+
if index:
|
|
131
|
+
history.append(query)
|
|
132
|
+
await ws.send(json.dumps({
|
|
133
|
+
"type": "kb_search", "request_id": f"long-{index}", "user_id": "user_123",
|
|
134
|
+
"query": query, "user_messages": history,
|
|
135
|
+
}))
|
|
136
|
+
frame = await _recv_type(ws, "kb_search_response")
|
|
137
|
+
assert frame["status"] == "success"
|
|
138
|
+
sent = json.loads(route.calls[index].request.content)
|
|
139
|
+
assert sent["query"] == query and sent["user_messages"] == history
|
|
140
|
+
await ws.send(json.dumps({"type": "kb_search", "request_id": "too-long", "query": "q" * 4001}))
|
|
141
|
+
assert (await _recv_type(ws, "kb_search_response"))["error"] == "INVALID_KB_SEARCH"
|
|
142
|
+
assert route.call_count == 2
|
|
143
|
+
finally:
|
|
144
|
+
await ws.close()
|
|
145
|
+
await client.aclose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/async_telemetry.py
RENAMED
|
File without changes
|
{centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/message_accumulator.py
RENAMED
|
File without changes
|
|
File without changes
|
{centive_sdk-2.2.0.dev8 → centive_sdk-2.2.0.dev9}/src/centive_sdk/resources/telemetry_batcher.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|