cluestate-sdk 0.1.0__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.
@@ -0,0 +1,42 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ *.egg
9
+ .eggs/
10
+
11
+ # Virtual environments
12
+ venv/
13
+ .venv/
14
+ env/
15
+
16
+ # IDE
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Environment
23
+ .env
24
+
25
+ # Database
26
+ *.db
27
+ *.sqlite
28
+ *.sqlite3
29
+
30
+ # OS
31
+ .DS_Store
32
+ Thumbs.db
33
+
34
+ # Node (admin UI & TypeScript SDK)
35
+ node_modules/
36
+ admin/node_modules/
37
+ admin/dist/
38
+ sdks/typescript/dist/
39
+
40
+ # Distribution
41
+ *.whl
42
+ *.tar.gz
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.5
2
+ Name: cluestate-sdk
3
+ Version: 0.1.0
4
+ Summary: Lightweight Python SDK for ClueState Customer Intelligence Infrastructure
5
+ Project-URL: Homepage, https://cluestate.com
6
+ Project-URL: Documentation, https://github.com/cluestate/cluestate/blob/main/docs/python-sdk.md
7
+ Project-URL: Repository, https://github.com/cluestate/cluestate
8
+ Author: ClueState Contributors
9
+ License-Expression: Apache-2.0
10
+ Keywords: ai,cluestate,customer-intelligence,entity-state,sdk
11
+ Requires-Python: >=3.9
12
+ Requires-Dist: httpx>=0.25.0
13
+ Requires-Dist: pydantic>=2.0.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # ClueState Python SDK
17
+
18
+ Lightweight, typed Python client (sync & async) for [ClueState](https://cluestate.com).
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ # Lightweight client-only package (depends only on httpx and pydantic)
24
+ pip install ./sdks/python
25
+
26
+ # Or if you have the full ClueState engine installed:
27
+ pip install .
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```python
33
+ from cluestate_sdk import ClueState
34
+
35
+ cs = ClueState(
36
+ api_key="cs_live_YOUR_API_KEY",
37
+ base_url="https://app.cluestate.com",
38
+ )
39
+
40
+ # 1. Define a schema topic
41
+ cs.topics.create(
42
+ name="annual_budget",
43
+ description="Approved annual software budget",
44
+ data_type="number",
45
+ cardinality="single",
46
+ update_policy="replace",
47
+ )
48
+
49
+ # 2. Ingest an interaction
50
+ result = cs.ingest(
51
+ external_id="cust_402",
52
+ entity_name="Marcus Vance",
53
+ content="Our approved software budget for Q4 is $25,000.",
54
+ source_type="support_ticket",
55
+ idempotency_key="evt_ticket_8841",
56
+ )
57
+
58
+ for mem in result.memories:
59
+ print(f"[{mem.topic_name}] {mem.claim} (value={mem.structured_value})")
60
+
61
+ # 3. Retrieve compiled customer state
62
+ intel = cs.get_intel("cust_402")
63
+ print(intel.formatted_prompt)
64
+ ```
65
+
66
+ ## Async Client (`AsyncClueState`)
67
+
68
+ ```python
69
+ import asyncio
70
+ from cluestate_sdk import AsyncClueState
71
+
72
+ async def main():
73
+ async with AsyncClueState(api_key="cs_live_YOUR_API_KEY") as cs:
74
+ intel = await cs.get_intel("cust_402")
75
+ print(intel.formatted_prompt)
76
+
77
+ asyncio.run(main())
78
+ ```
79
+
@@ -0,0 +1,64 @@
1
+ # ClueState Python SDK
2
+
3
+ Lightweight, typed Python client (sync & async) for [ClueState](https://cluestate.com).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # Lightweight client-only package (depends only on httpx and pydantic)
9
+ pip install ./sdks/python
10
+
11
+ # Or if you have the full ClueState engine installed:
12
+ pip install .
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```python
18
+ from cluestate_sdk import ClueState
19
+
20
+ cs = ClueState(
21
+ api_key="cs_live_YOUR_API_KEY",
22
+ base_url="https://app.cluestate.com",
23
+ )
24
+
25
+ # 1. Define a schema topic
26
+ cs.topics.create(
27
+ name="annual_budget",
28
+ description="Approved annual software budget",
29
+ data_type="number",
30
+ cardinality="single",
31
+ update_policy="replace",
32
+ )
33
+
34
+ # 2. Ingest an interaction
35
+ result = cs.ingest(
36
+ external_id="cust_402",
37
+ entity_name="Marcus Vance",
38
+ content="Our approved software budget for Q4 is $25,000.",
39
+ source_type="support_ticket",
40
+ idempotency_key="evt_ticket_8841",
41
+ )
42
+
43
+ for mem in result.memories:
44
+ print(f"[{mem.topic_name}] {mem.claim} (value={mem.structured_value})")
45
+
46
+ # 3. Retrieve compiled customer state
47
+ intel = cs.get_intel("cust_402")
48
+ print(intel.formatted_prompt)
49
+ ```
50
+
51
+ ## Async Client (`AsyncClueState`)
52
+
53
+ ```python
54
+ import asyncio
55
+ from cluestate_sdk import AsyncClueState
56
+
57
+ async def main():
58
+ async with AsyncClueState(api_key="cs_live_YOUR_API_KEY") as cs:
59
+ intel = await cs.get_intel("cust_402")
60
+ print(intel.formatted_prompt)
61
+
62
+ asyncio.run(main())
63
+ ```
64
+
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cluestate-sdk"
7
+ version = "0.1.0"
8
+ description = "Lightweight Python SDK for ClueState Customer Intelligence Infrastructure"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "ClueState Contributors" },
14
+ ]
15
+ keywords = ["cluestate", "customer-intelligence", "entity-state", "sdk", "ai"]
16
+ dependencies = [
17
+ "httpx>=0.25.0",
18
+ "pydantic>=2.0.0",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://cluestate.com"
23
+ Documentation = "https://github.com/cluestate/cluestate/blob/main/docs/python-sdk.md"
24
+ Repository = "https://github.com/cluestate/cluestate"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/cluestate_sdk"]
28
+
@@ -0,0 +1,636 @@
1
+ """Standalone lightweight Python SDK for ClueState (depends only on httpx and pydantic)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import datetime
7
+ from typing import Any, Literal, Optional, Union
8
+
9
+ import httpx
10
+ from pydantic import BaseModel, Field
11
+
12
+ DEFAULT_BASE_URL = "https://app.cluestate.com"
13
+
14
+ EntityTypeLiteral = Literal["person", "organization", "product", "location", "custom"]
15
+ MemoryStatusLiteral = Literal["active", "confirmed", "rejected", "superseded"]
16
+ ClaimTypeLiteral = Literal["explicit", "inferred", "observed"]
17
+ TopicCardinalityLiteral = Literal["single", "multiple"]
18
+ UpdatePolicyLiteral = Literal["replace", "append", "merge", "review"]
19
+ SourceTypeLiteral = Literal[
20
+ "conversation",
21
+ "email",
22
+ "survey",
23
+ "purchase",
24
+ "support_ticket",
25
+ "call_transcript",
26
+ "app_interaction",
27
+ "review",
28
+ "social_media",
29
+ "note",
30
+ "custom",
31
+ ]
32
+
33
+
34
+ class ClueStateError(Exception):
35
+ """Raised when the ClueState API returns an error response."""
36
+
37
+ def __init__(self, message: str, status_code: Optional[int] = None, body: Any = None):
38
+ super().__init__(message)
39
+ self.message = message
40
+ self.status_code = status_code
41
+ self.body = body
42
+
43
+ def __str__(self) -> str:
44
+ if self.status_code:
45
+ return f"[HTTP {self.status_code}] {self.message}"
46
+ return self.message
47
+
48
+
49
+ # ──────────────────────────────────────────────
50
+ # Response Models
51
+ # ──────────────────────────────────────────────
52
+
53
+ class Evidence(BaseModel):
54
+ id: int
55
+ episode_id: int
56
+ excerpt: str
57
+ is_verified: bool = False
58
+ char_start: Optional[int] = None
59
+ char_end: Optional[int] = None
60
+ content_hash: str = ""
61
+ created_at: datetime
62
+
63
+
64
+ class Memory(BaseModel):
65
+ id: int
66
+ entity_id: int
67
+ topic_id: Optional[int] = None
68
+ topic_name: Optional[str] = None
69
+ claim: str
70
+ value: str
71
+ structured_value: Optional[Any] = None
72
+ claim_type: str = "explicit"
73
+ confidence: float
74
+ status: str
75
+ superseded_by_id: Optional[int] = None
76
+ valid_from: Optional[datetime] = None
77
+ valid_to: Optional[datetime] = None
78
+ created_at: datetime
79
+ updated_at: datetime
80
+ evidence: list[Evidence] = Field(default_factory=list)
81
+
82
+
83
+ class Entity(BaseModel):
84
+ id: int
85
+ external_id: str
86
+ name: str
87
+ entity_type: str
88
+ meta: dict[str, Any] = Field(default_factory=dict)
89
+ created_at: datetime
90
+ updated_at: datetime
91
+ memory_count: int = 0
92
+ memories: list[Memory] = Field(default_factory=list)
93
+
94
+
95
+ class Topic(BaseModel):
96
+ id: int
97
+ name: str
98
+ description: str
99
+ data_type: str
100
+ cardinality: str = "multiple"
101
+ update_policy: str = "append"
102
+ temporal: bool = False
103
+ enum_values: list[str] = Field(default_factory=list)
104
+ schema_version: int = 1
105
+ is_active: bool = True
106
+ created_at: datetime
107
+
108
+
109
+ class Episode(BaseModel):
110
+ id: int
111
+ entity_id: int
112
+ source_type: str
113
+ source_record_id: Optional[str] = None
114
+ content: str
115
+ content_hash: str = ""
116
+ source_description: str = ""
117
+ meta: dict[str, Any] = Field(default_factory=dict)
118
+ occurred_at: datetime
119
+ created_at: datetime
120
+
121
+
122
+ class IngestResponse(BaseModel):
123
+ episode_id: int
124
+ entity_id: int
125
+ memories_extracted: int
126
+ memories_updated: int
127
+ memories_new: int
128
+ idempotent_replay: bool = False
129
+ memories: list[Memory] = Field(default_factory=list)
130
+
131
+
132
+ class IntelResponse(BaseModel):
133
+ entity_id: int
134
+ entity_name: str
135
+ entity_type: str
136
+ formatted_prompt: str
137
+ memories: list[Memory] = Field(default_factory=list)
138
+ total_memories: int = 0
139
+ total_episodes: int = 0
140
+
141
+
142
+ def _build_headers(api_key: str) -> dict[str, str]:
143
+ headers = {
144
+ "Content-Type": "application/json",
145
+ "Accept": "application/json",
146
+ "User-Agent": "cluestate-sdk-python/0.1.0",
147
+ }
148
+ if api_key:
149
+ headers["Authorization"] = f"Bearer {api_key}"
150
+ return headers
151
+
152
+
153
+ def _normalize_base_url(base_url: str) -> str:
154
+ url = base_url.rstrip("/")
155
+ if not url.endswith("/api"):
156
+ url = f"{url}/api"
157
+ return url
158
+
159
+
160
+ def _raise_for_response(response: httpx.Response) -> None:
161
+ if response.is_success:
162
+ return
163
+ detail = response.reason_phrase or "Request failed"
164
+ body = None
165
+ try:
166
+ body = response.json()
167
+ if isinstance(body, dict) and "detail" in body:
168
+ detail = str(body["detail"])
169
+ except Exception:
170
+ detail = response.text or detail
171
+ raise ClueStateError(message=detail, status_code=response.status_code, body=body)
172
+
173
+
174
+ # ──────────────────────────────────────────────
175
+ # Sync Resources & Client
176
+ # ──────────────────────────────────────────────
177
+
178
+ class EntitiesResource:
179
+ def __init__(self, client: "ClueState"):
180
+ self._client = client
181
+
182
+ def create(
183
+ self,
184
+ external_id: str,
185
+ name: str = "",
186
+ entity_type: EntityTypeLiteral = "person",
187
+ meta: Optional[dict[str, Any]] = None,
188
+ ) -> Entity:
189
+ data = self._client._request(
190
+ "POST",
191
+ "/entities",
192
+ json={"external_id": external_id, "name": name, "entity_type": entity_type, "meta": meta or {}},
193
+ )
194
+ return Entity.model_validate(data)
195
+
196
+ def list(self, search: str = "", limit: int = 50, offset: int = 0) -> list[Entity]:
197
+ data = self._client._request("GET", "/entities", params={"search": search, "limit": limit, "offset": offset})
198
+ return [Entity.model_validate(item) for item in data]
199
+
200
+ def get(self, entity_id: int) -> Entity:
201
+ data = self._client._request("GET", f"/entities/{entity_id}")
202
+ return Entity.model_validate(data)
203
+
204
+ def episodes(self, entity_id: int, limit: int = 50) -> list[Episode]:
205
+ data = self._client._request("GET", f"/entities/{entity_id}/episodes", params={"limit": limit})
206
+ return [Episode.model_validate(item) for item in data]
207
+
208
+ def delete(self, entity_id: int) -> None:
209
+ self._client._request("DELETE", f"/entities/{entity_id}")
210
+
211
+
212
+ class MemoriesResource:
213
+ def __init__(self, client: "ClueState"):
214
+ self._client = client
215
+
216
+ def list(
217
+ self,
218
+ entity_id: int,
219
+ status: Optional[MemoryStatusLiteral] = None,
220
+ topic_id: Optional[int] = None,
221
+ ) -> list[Memory]:
222
+ params: dict[str, Any] = {}
223
+ if status is not None:
224
+ params["status"] = status
225
+ if topic_id is not None:
226
+ params["topic_id"] = topic_id
227
+ data = self._client._request("GET", f"/entities/{entity_id}/memories", params=params)
228
+ return [Memory.model_validate(item) for item in data]
229
+
230
+ def update(
231
+ self,
232
+ memory_id: int,
233
+ status: Optional[MemoryStatusLiteral] = None,
234
+ confidence: Optional[float] = None,
235
+ ) -> Memory:
236
+ payload: dict[str, Any] = {}
237
+ if status is not None:
238
+ payload["status"] = status
239
+ if confidence is not None:
240
+ payload["confidence"] = confidence
241
+ data = self._client._request("PATCH", f"/memories/{memory_id}", json=payload)
242
+ return Memory.model_validate(data)
243
+
244
+ def confirm(self, memory_id: int) -> Memory:
245
+ return self.update(memory_id=memory_id, status="confirmed")
246
+
247
+ def reject(self, memory_id: int) -> Memory:
248
+ return self.update(memory_id=memory_id, status="rejected")
249
+
250
+ def delete(self, memory_id: int) -> None:
251
+ self._client._request("DELETE", f"/memories/{memory_id}")
252
+
253
+
254
+ class TopicsResource:
255
+ def __init__(self, client: "ClueState"):
256
+ self._client = client
257
+
258
+ def create(
259
+ self,
260
+ name: str,
261
+ description: str = "",
262
+ data_type: str = "text",
263
+ cardinality: TopicCardinalityLiteral = "multiple",
264
+ update_policy: UpdatePolicyLiteral = "append",
265
+ temporal: bool = False,
266
+ enum_values: Optional[list[str]] = None,
267
+ ) -> Topic:
268
+ data = self._client._request(
269
+ "POST",
270
+ "/topics",
271
+ json={
272
+ "name": name,
273
+ "description": description,
274
+ "data_type": data_type,
275
+ "cardinality": cardinality,
276
+ "update_policy": update_policy,
277
+ "temporal": temporal,
278
+ "enum_values": enum_values or [],
279
+ },
280
+ )
281
+ return Topic.model_validate(data)
282
+
283
+ def list(self, active_only: bool = True) -> list[Topic]:
284
+ data = self._client._request("GET", "/topics", params={"active_only": active_only})
285
+ return [Topic.model_validate(item) for item in data]
286
+
287
+ def update(self, topic_id: int, **kwargs: Any) -> Topic:
288
+ payload = {k: v for k, v in kwargs.items() if v is not None}
289
+ data = self._client._request("PUT", f"/topics/{topic_id}", json=payload)
290
+ return Topic.model_validate(data)
291
+
292
+ def delete(self, topic_id: int) -> None:
293
+ self._client._request("DELETE", f"/topics/{topic_id}")
294
+
295
+
296
+ class ClueState:
297
+ """Synchronous ClueState SDK Client."""
298
+
299
+ def __init__(
300
+ self,
301
+ api_key: Optional[str] = None,
302
+ base_url: Optional[str] = None,
303
+ timeout: float = 60.0,
304
+ http_client: Optional[httpx.Client] = None,
305
+ ):
306
+ self.api_key = api_key if api_key is not None else os.getenv("CLUESTATE_API_KEY", "")
307
+ raw_url = base_url or os.getenv("CLUESTATE_API_URL") or DEFAULT_BASE_URL
308
+ self.base_url = _normalize_base_url(raw_url)
309
+ self._owns_client = http_client is None
310
+ self._http = http_client or httpx.Client(
311
+ base_url=self.base_url,
312
+ headers=_build_headers(self.api_key),
313
+ timeout=timeout,
314
+ )
315
+ self.entities = EntitiesResource(self)
316
+ self.memories = MemoriesResource(self)
317
+ self.topics = TopicsResource(self)
318
+
319
+ def _request(
320
+ self,
321
+ method: str,
322
+ path: str,
323
+ params: Optional[dict[str, Any]] = None,
324
+ json: Optional[dict[str, Any]] = None,
325
+ ) -> Any:
326
+ res = self._http.request(method, path, params=params, json=json)
327
+ _raise_for_response(res)
328
+ if res.status_code == 204 or not res.content:
329
+ return None
330
+ return res.json()
331
+
332
+ def ingest(
333
+ self,
334
+ content: str,
335
+ *,
336
+ external_id: Optional[str] = None,
337
+ entity_id: Optional[int] = None,
338
+ entity_name: str = "",
339
+ source_type: SourceTypeLiteral = "conversation",
340
+ source_record_id: Optional[str] = None,
341
+ idempotency_key: Optional[str] = None,
342
+ occurred_at: Optional[Union[datetime, str]] = None,
343
+ source_description: str = "",
344
+ meta: Optional[dict[str, Any]] = None,
345
+ auto_create_entity: bool = True,
346
+ ) -> IngestResponse:
347
+ if not external_id and not entity_id:
348
+ raise ValueError("Provide either 'external_id' or 'entity_id'")
349
+
350
+ payload: dict[str, Any] = {
351
+ "content": content,
352
+ "entity_name": entity_name,
353
+ "source_type": source_type,
354
+ "source_description": source_description,
355
+ "meta": meta or {},
356
+ "auto_create_entity": auto_create_entity,
357
+ }
358
+ if external_id is not None:
359
+ payload["external_id"] = external_id
360
+ if entity_id is not None:
361
+ payload["entity_id"] = entity_id
362
+ if source_record_id is not None:
363
+ payload["source_record_id"] = source_record_id
364
+ if idempotency_key is not None:
365
+ payload["idempotency_key"] = idempotency_key
366
+ if occurred_at is not None:
367
+ payload["occurred_at"] = (
368
+ occurred_at.isoformat() if isinstance(occurred_at, datetime) else occurred_at
369
+ )
370
+
371
+ data = self._request("POST", "/ingest", json=payload)
372
+ return IngestResponse.model_validate(data)
373
+
374
+ def get_intel(
375
+ self,
376
+ external_id: Optional[str] = None,
377
+ *,
378
+ entity_id: Optional[int] = None,
379
+ ) -> IntelResponse:
380
+ if external_id:
381
+ data = self._request("GET", f"/intel/by-external-id/{external_id}")
382
+ elif entity_id is not None:
383
+ data = self._request("GET", f"/entities/{entity_id}/intel")
384
+ else:
385
+ raise ValueError("Provide either 'external_id' or 'entity_id'")
386
+ return IntelResponse.model_validate(data)
387
+
388
+ def close(self) -> None:
389
+ if self._owns_client:
390
+ self._http.close()
391
+
392
+ def __enter__(self) -> "ClueState":
393
+ return self
394
+
395
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
396
+ self.close()
397
+
398
+
399
+ # ──────────────────────────────────────────────
400
+ # Async Resources & Client
401
+ # ──────────────────────────────────────────────
402
+
403
+ class AsyncEntitiesResource:
404
+ def __init__(self, client: "AsyncClueState"):
405
+ self._client = client
406
+
407
+ async def create(
408
+ self,
409
+ external_id: str,
410
+ name: str = "",
411
+ entity_type: EntityTypeLiteral = "person",
412
+ meta: Optional[dict[str, Any]] = None,
413
+ ) -> Entity:
414
+ data = await self._client._request(
415
+ "POST",
416
+ "/entities",
417
+ json={"external_id": external_id, "name": name, "entity_type": entity_type, "meta": meta or {}},
418
+ )
419
+ return Entity.model_validate(data)
420
+
421
+ async def list(self, search: str = "", limit: int = 50, offset: int = 0) -> list[Entity]:
422
+ data = await self._client._request("GET", "/entities", params={"search": search, "limit": limit, "offset": offset})
423
+ return [Entity.model_validate(item) for item in data]
424
+
425
+ async def get(self, entity_id: int) -> Entity:
426
+ data = await self._client._request("GET", f"/entities/{entity_id}")
427
+ return Entity.model_validate(data)
428
+
429
+ async def episodes(self, entity_id: int, limit: int = 50) -> list[Episode]:
430
+ data = await self._client._request("GET", f"/entities/{entity_id}/episodes", params={"limit": limit})
431
+ return [Episode.model_validate(item) for item in data]
432
+
433
+ async def delete(self, entity_id: int) -> None:
434
+ await self._client._request("DELETE", f"/entities/{entity_id}")
435
+
436
+
437
+ class AsyncMemoriesResource:
438
+ def __init__(self, client: "AsyncClueState"):
439
+ self._client = client
440
+
441
+ async def list(
442
+ self,
443
+ entity_id: int,
444
+ status: Optional[MemoryStatusLiteral] = None,
445
+ topic_id: Optional[int] = None,
446
+ ) -> list[Memory]:
447
+ params: dict[str, Any] = {}
448
+ if status is not None:
449
+ params["status"] = status
450
+ if topic_id is not None:
451
+ params["topic_id"] = topic_id
452
+ data = await self._client._request("GET", f"/entities/{entity_id}/memories", params=params)
453
+ return [Memory.model_validate(item) for item in data]
454
+
455
+ async def update(
456
+ self,
457
+ memory_id: int,
458
+ status: Optional[MemoryStatusLiteral] = None,
459
+ confidence: Optional[float] = None,
460
+ ) -> Memory:
461
+ payload: dict[str, Any] = {}
462
+ if status is not None:
463
+ payload["status"] = status
464
+ if confidence is not None:
465
+ payload["confidence"] = confidence
466
+ data = await self._client._request("PATCH", f"/memories/{memory_id}", json=payload)
467
+ return Memory.model_validate(data)
468
+
469
+ async def confirm(self, memory_id: int) -> Memory:
470
+ return await self.update(memory_id=memory_id, status="confirmed")
471
+
472
+ async def reject(self, memory_id: int) -> Memory:
473
+ return await self.update(memory_id=memory_id, status="rejected")
474
+
475
+ async def delete(self, memory_id: int) -> None:
476
+ await self._client._request("DELETE", f"/memories/{memory_id}")
477
+
478
+
479
+ class AsyncTopicsResource:
480
+ def __init__(self, client: "AsyncClueState"):
481
+ self._client = client
482
+
483
+ async def create(
484
+ self,
485
+ name: str,
486
+ description: str = "",
487
+ data_type: str = "text",
488
+ cardinality: TopicCardinalityLiteral = "multiple",
489
+ update_policy: UpdatePolicyLiteral = "append",
490
+ temporal: bool = False,
491
+ enum_values: Optional[list[str]] = None,
492
+ ) -> Topic:
493
+ data = await self._client._request(
494
+ "POST",
495
+ "/topics",
496
+ json={
497
+ "name": name,
498
+ "description": description,
499
+ "data_type": data_type,
500
+ "cardinality": cardinality,
501
+ "update_policy": update_policy,
502
+ "temporal": temporal,
503
+ "enum_values": enum_values or [],
504
+ },
505
+ )
506
+ return Topic.model_validate(data)
507
+
508
+ async def list(self, active_only: bool = True) -> list[Topic]:
509
+ data = await self._client._request("GET", "/topics", params={"active_only": active_only})
510
+ return [Topic.model_validate(item) for item in data]
511
+
512
+ async def update(self, topic_id: int, **kwargs: Any) -> Topic:
513
+ payload = {k: v for k, v in kwargs.items() if v is not None}
514
+ data = await self._client._request("PUT", f"/topics/{topic_id}", json=payload)
515
+ return Topic.model_validate(data)
516
+
517
+ async def delete(self, topic_id: int) -> None:
518
+ await self._client._request("DELETE", f"/topics/{topic_id}")
519
+
520
+
521
+ class AsyncClueState:
522
+ """Asynchronous ClueState SDK Client."""
523
+
524
+ def __init__(
525
+ self,
526
+ api_key: Optional[str] = None,
527
+ base_url: Optional[str] = None,
528
+ timeout: float = 60.0,
529
+ http_client: Optional[httpx.AsyncClient] = None,
530
+ ):
531
+ self.api_key = api_key if api_key is not None else os.getenv("CLUESTATE_API_KEY", "")
532
+ raw_url = base_url or os.getenv("CLUESTATE_API_URL") or DEFAULT_BASE_URL
533
+ self.base_url = _normalize_base_url(raw_url)
534
+ self._owns_client = http_client is None
535
+ self._http = http_client or httpx.AsyncClient(
536
+ base_url=self.base_url,
537
+ headers=_build_headers(self.api_key),
538
+ timeout=timeout,
539
+ )
540
+ self.entities = AsyncEntitiesResource(self)
541
+ self.memories = AsyncMemoriesResource(self)
542
+ self.topics = AsyncTopicsResource(self)
543
+
544
+ async def _request(
545
+ self,
546
+ method: str,
547
+ path: str,
548
+ params: Optional[dict[str, Any]] = None,
549
+ json: Optional[dict[str, Any]] = None,
550
+ ) -> Any:
551
+ res = await self._http.request(method, path, params=params, json=json)
552
+ _raise_for_response(res)
553
+ if res.status_code == 204 or not res.content:
554
+ return None
555
+ return res.json()
556
+
557
+ async def ingest(
558
+ self,
559
+ content: str,
560
+ *,
561
+ external_id: Optional[str] = None,
562
+ entity_id: Optional[int] = None,
563
+ entity_name: str = "",
564
+ source_type: SourceTypeLiteral = "conversation",
565
+ source_record_id: Optional[str] = None,
566
+ idempotency_key: Optional[str] = None,
567
+ occurred_at: Optional[Union[datetime, str]] = None,
568
+ source_description: str = "",
569
+ meta: Optional[dict[str, Any]] = None,
570
+ auto_create_entity: bool = True,
571
+ ) -> IngestResponse:
572
+ if not external_id and not entity_id:
573
+ raise ValueError("Provide either 'external_id' or 'entity_id'")
574
+
575
+ payload: dict[str, Any] = {
576
+ "content": content,
577
+ "entity_name": entity_name,
578
+ "source_type": source_type,
579
+ "source_description": source_description,
580
+ "meta": meta or {},
581
+ "auto_create_entity": auto_create_entity,
582
+ }
583
+ if external_id is not None:
584
+ payload["external_id"] = external_id
585
+ if entity_id is not None:
586
+ payload["entity_id"] = entity_id
587
+ if source_record_id is not None:
588
+ payload["source_record_id"] = source_record_id
589
+ if idempotency_key is not None:
590
+ payload["idempotency_key"] = idempotency_key
591
+ if occurred_at is not None:
592
+ payload["occurred_at"] = (
593
+ occurred_at.isoformat() if isinstance(occurred_at, datetime) else occurred_at
594
+ )
595
+
596
+ data = await self._request("POST", "/ingest", json=payload)
597
+ return IngestResponse.model_validate(data)
598
+
599
+ async def get_intel(
600
+ self,
601
+ external_id: Optional[str] = None,
602
+ *,
603
+ entity_id: Optional[int] = None,
604
+ ) -> IntelResponse:
605
+ if external_id:
606
+ data = await self._request("GET", f"/intel/by-external-id/{external_id}")
607
+ elif entity_id is not None:
608
+ data = await self._request("GET", f"/entities/{entity_id}/intel")
609
+ else:
610
+ raise ValueError("Provide either 'external_id' or 'entity_id'")
611
+ return IntelResponse.model_validate(data)
612
+
613
+ async def close(self) -> None:
614
+ if self._owns_client:
615
+ await self._http.aclose()
616
+
617
+ async def __aenter__(self) -> "AsyncClueState":
618
+ return self
619
+
620
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
621
+ await self.close()
622
+
623
+
624
+ __all__ = [
625
+ "ClueState",
626
+ "AsyncClueState",
627
+ "ClueStateError",
628
+ "Entity",
629
+ "Memory",
630
+ "Evidence",
631
+ "Topic",
632
+ "Episode",
633
+ "IngestResponse",
634
+ "IntelResponse",
635
+ ]
636
+