acis-memory 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
acis/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """ACIS SDK — AI Context & Intelligent System.
2
+
3
+ Usage:
4
+ from acis import MemoryClient
5
+
6
+ client = MemoryClient(tenant_id="your-tenant", base_url="http://acis-server:8000")
7
+ """
8
+ from acis.client import (
9
+ MemoryClient,
10
+ SessionResult,
11
+ CloseResult,
12
+ WriteResult,
13
+ ContextResult,
14
+ SearchResult,
15
+ MemoryHistoryEntry,
16
+ )
17
+ from acis.exceptions import (
18
+ ACISError,
19
+ ACISRateLimitError,
20
+ ACISSessionNotFoundError,
21
+ ACISCircuitOpenError,
22
+ ACISValidationError,
23
+ )
24
+
25
+ __version__ = "1.0.0"
26
+
27
+ __all__ = [
28
+ "MemoryClient",
29
+ "SessionResult",
30
+ "CloseResult",
31
+ "WriteResult",
32
+ "ContextResult",
33
+ "SearchResult",
34
+ "MemoryHistoryEntry",
35
+ "ACISError",
36
+ "ACISRateLimitError",
37
+ "ACISSessionNotFoundError",
38
+ "ACISCircuitOpenError",
39
+ "ACISValidationError",
40
+ ]
acis/client.py ADDED
@@ -0,0 +1,369 @@
1
+ """ACIS MemoryClient — primary SDK entry point (HTTP-backed).
2
+
3
+ Usage:
4
+ from acis import MemoryClient
5
+
6
+ client = MemoryClient(tenant_id="abc-123", base_url="http://acis-server:8000")
7
+ session = client.create_session(user_id="user-1")
8
+ client.add_event(session_id=session.session_id, role="user", content="Hello")
9
+ ctx = client.get_context(session_id=session.session_id, query="Hello")
10
+ # ctx.messages → pass directly to any LLM
11
+ """
12
+ import logging
13
+ from dataclasses import dataclass
14
+ from typing import Optional
15
+
16
+ import httpx
17
+
18
+ from acis.exceptions import (
19
+ ACISError,
20
+ ACISRateLimitError,
21
+ ACISSessionNotFoundError,
22
+ ACISCircuitOpenError,
23
+ ACISValidationError,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ DEFAULT_BASE_URL = "http://localhost:8000"
29
+ API_PREFIX = "/api/v1"
30
+ DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0)
31
+
32
+
33
+ @dataclass
34
+ class SessionResult:
35
+ session_id: str
36
+ tenant_id: str
37
+ user_id: str
38
+ agent_id: Optional[str] = None
39
+
40
+
41
+ @dataclass
42
+ class CloseResult:
43
+ session_id: str
44
+ closed: bool
45
+ close_reason: str = "user_ended"
46
+ closed_at: Optional[str] = None
47
+ already_closed: bool = False
48
+
49
+
50
+ @dataclass
51
+ class WriteResult:
52
+ event_id: str
53
+ seq: int
54
+ created: bool
55
+
56
+
57
+ @dataclass
58
+ class ContextResult:
59
+ messages: list
60
+ context_payload: list
61
+ events_injected: int
62
+ memories_injected: int
63
+ summary_injected: bool
64
+ rules_injected: int
65
+ total_tokens: int
66
+ token_budget: int
67
+ remaining_budget: int
68
+ memory_ids_used: list
69
+
70
+
71
+ @dataclass
72
+ class SearchResult:
73
+ memory_id: str
74
+ content: str
75
+ category: str
76
+ importance: float
77
+ agent_id: Optional[str] = None
78
+ entities: Optional[dict] = None
79
+ rrf_score: Optional[float] = None
80
+
81
+
82
+ @dataclass
83
+ class MemoryHistoryEntry:
84
+ memory_id: str
85
+ content: str
86
+ category: str
87
+ importance: float
88
+ created_at: Optional[str] = None
89
+ valid_to: Optional[str] = None
90
+ superseded_by: Optional[str] = None
91
+
92
+
93
+ class MemoryClient:
94
+ """ACIS Python SDK — single entry point for all memory operations.
95
+
96
+ Args:
97
+ tenant_id: Tenant UUID (required)
98
+ user_id: Default user ID (optional, can override per-call)
99
+ agent_id: Default agent ID for scoped operations (optional)
100
+ base_url: ACIS API base URL (e.g. http://acis-server:8000)
101
+ timeout: HTTP timeout configuration (optional)
102
+
103
+ Example:
104
+ from acis import MemoryClient
105
+
106
+ client = MemoryClient(
107
+ tenant_id="abc-123",
108
+ base_url="http://10.0.1.50:8000"
109
+ )
110
+ session = client.create_session(user_id="user-1")
111
+ client.add_event(session_id=session.session_id, role="user", content="Hello")
112
+ ctx = client.get_context(session_id=session.session_id, query="Hello")
113
+ response = your_llm(messages=ctx.messages)
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ tenant_id: str,
119
+ user_id: Optional[str] = None,
120
+ agent_id: Optional[str] = None,
121
+ base_url: str = DEFAULT_BASE_URL,
122
+ timeout: Optional[httpx.Timeout] = None,
123
+ ):
124
+ if not tenant_id or not tenant_id.strip():
125
+ raise ACISValidationError("tenant_id is required and cannot be empty")
126
+
127
+ self.tenant_id = tenant_id.strip()
128
+ self.user_id = user_id
129
+ self.agent_id = agent_id
130
+ self.base_url = base_url.rstrip("/")
131
+ self._api_url = f"{self.base_url}{API_PREFIX}"
132
+
133
+ self._http = httpx.Client(
134
+ base_url=self._api_url,
135
+ headers={"X-Tenant-Id": self.tenant_id},
136
+ timeout=timeout or DEFAULT_TIMEOUT,
137
+ )
138
+ self._resolved_session_id: Optional[str] = None
139
+ logger.info(f"[ACIS SDK] Initialized: tenant={self.tenant_id}, api={self._api_url}")
140
+
141
+ def close(self):
142
+ """Close the underlying HTTP client."""
143
+ self._http.close()
144
+
145
+ def __enter__(self):
146
+ return self
147
+
148
+ def __exit__(self, *args):
149
+ self.close()
150
+
151
+ # ─── HTTP Helpers ─────────────────────────────────────────────
152
+
153
+ def _request(self, method: str, path: str, **kwargs) -> dict:
154
+ try:
155
+ response = self._http.request(method, path, **kwargs)
156
+ except httpx.ConnectError as e:
157
+ raise ACISError(f"Cannot connect to ACIS at {self.base_url}: {e}", code="CONNECTION_ERROR") from e
158
+ except httpx.TimeoutException as e:
159
+ raise ACISError(f"Request to ACIS timed out: {e}", code="TIMEOUT") from e
160
+
161
+ if response.status_code in (200, 201, 202):
162
+ return response.json()
163
+
164
+ try:
165
+ body = response.json()
166
+ except Exception:
167
+ body = {"error": response.text}
168
+
169
+ detail = body.get("detail") or body.get("error", "Unknown error")
170
+
171
+ if response.status_code == 400:
172
+ raise ACISValidationError(detail)
173
+ elif response.status_code == 404:
174
+ raise ACISSessionNotFoundError(detail)
175
+ elif response.status_code == 429:
176
+ raise ACISRateLimitError(detail, retry_after=float(response.headers.get("Retry-After", 0)))
177
+ elif response.status_code == 503:
178
+ raise ACISCircuitOpenError()
179
+ else:
180
+ raise ACISError(detail, code=body.get("code", "UNKNOWN"))
181
+
182
+ def _post(self, path: str, json: dict) -> dict:
183
+ return self._request("POST", path, json=json)
184
+
185
+ def _get(self, path: str, params: dict = None) -> dict:
186
+ return self._request("GET", path, params=params)
187
+
188
+ def _delete(self, path: str) -> dict:
189
+ return self._request("DELETE", path)
190
+
191
+ # ─── Session Resolution ───────────────────────────────────────
192
+
193
+ def _resolve_session(self, session_id=None, user_id=None, agent_id=None):
194
+ if session_id:
195
+ return session_id
196
+ uid = user_id or self.user_id
197
+ if not uid:
198
+ raise ACISValidationError("user_id is required for automatic session resolution")
199
+ if self._resolved_session_id:
200
+ return self._resolved_session_id
201
+ result = self.create_session(user_id=uid, agent_id=agent_id)
202
+ self._resolved_session_id = result.session_id
203
+ return result.session_id
204
+
205
+ # ─── Session Lifecycle ────────────────────────────────────────
206
+
207
+ def create_session(self, user_id=None, agent_id=None, llm_model_id=None, token_budget=None, meta=None) -> SessionResult:
208
+ """Create a new conversation session."""
209
+ uid = user_id or self.user_id
210
+ aid = agent_id or self.agent_id
211
+ if not uid:
212
+ raise ACISValidationError("user_id is required for create_session")
213
+ self._resolved_session_id = None
214
+ body = {"user_id": uid}
215
+ if aid: body["agent_id"] = aid
216
+ if llm_model_id: body["llm_model_id"] = llm_model_id
217
+ if token_budget is not None: body["token_budget"] = token_budget
218
+ if meta: body["meta"] = meta
219
+ data = self._post("/sessions", json=body)
220
+ return SessionResult(session_id=data["session_id"], tenant_id=data.get("tenant_id", self.tenant_id), user_id=uid, agent_id=data.get("agent_id", aid))
221
+
222
+ def close_session(self, session_id=None, reason="user_ended") -> CloseResult:
223
+ """Close a session (triggers async memory extraction)."""
224
+ sid = session_id or self._resolved_session_id
225
+ if not sid:
226
+ raise ACISValidationError("session_id is required")
227
+ data = self._post(f"/sessions/{sid}/close", json={"close_reason": reason})
228
+ if sid == self._resolved_session_id:
229
+ self._resolved_session_id = None
230
+ return CloseResult(session_id=data.get("session_id", sid), closed=True, close_reason=data.get("close_reason", reason), closed_at=data.get("closed_at"), already_closed=data.get("already_closed", False))
231
+
232
+ # ─── Write Events ─────────────────────────────────────────────
233
+
234
+ def add_event(self, session_id=None, role="user", content="", event_type="message", user_id=None, agent_id=None, token_count=None, tool_call_id=None, idempotency_key=None, attachments=None) -> WriteResult:
235
+ """Write a single event (message, tool_call, tool_result, etc.)."""
236
+ if not content and event_type == "message":
237
+ raise ACISValidationError("content is required for message events")
238
+ uid = user_id or self.user_id
239
+ if not uid:
240
+ raise ACISValidationError("user_id is required")
241
+ resolved_sid = self._resolve_session(session_id, uid, agent_id or self.agent_id)
242
+ body = {"session_id": resolved_sid, "user_id": uid, "event_type": event_type, "role": role, "content": content}
243
+ if agent_id or self.agent_id: body["agent_id"] = agent_id or self.agent_id
244
+ if token_count is not None: body["token_count"] = token_count
245
+ if tool_call_id: body["tool_call_id"] = tool_call_id
246
+ if idempotency_key: body["idempotency_key"] = idempotency_key
247
+ if attachments: body["attachments"] = attachments
248
+ data = self._post("/events", json=body)
249
+ return WriteResult(event_id=data["event_id"], seq=data["seq"], created=data["created"])
250
+
251
+ def write_batch(self, session_id=None, events=None, user_id=None, agent_id=None) -> list:
252
+ """Batch write multiple events atomically."""
253
+ if not events or not isinstance(events, list):
254
+ raise ACISValidationError("events must be a non-empty list")
255
+ if len(events) > 50:
256
+ raise ACISValidationError("write_batch limited to 50 events per call")
257
+ uid = user_id or self.user_id
258
+ if not uid:
259
+ raise ACISValidationError("user_id is required")
260
+ resolved_sid = self._resolve_session(session_id, uid, agent_id or self.agent_id)
261
+ body = {
262
+ "session_id": resolved_sid, "user_id": uid,
263
+ "events": [{"user_id": uid, "event_type": e.get("event_type", "message"), "role": e.get("role", "user"), "content": e.get("content", ""), "token_count": e.get("token_count"), "tool_call_id": e.get("tool_call_id"), "idempotency_key": e.get("idempotency_key")} for e in events],
264
+ }
265
+ if agent_id or self.agent_id: body["agent_id"] = agent_id or self.agent_id
266
+ data = self._post("/events/batch", json=body)
267
+ return [WriteResult(event_id=e["event_id"], seq=e["seq"], created=e["created"]) for e in data.get("events", [])]
268
+
269
+ # ─── Context Assembly ─────────────────────────────────────────
270
+
271
+ def get_context(self, session_id=None, query="", user_id=None, agent_id=None, model_id="gpt-4o") -> ContextResult:
272
+ """Get token-budgeted context for your LLM call. Returns ctx.messages ready for any LLM."""
273
+ if not query:
274
+ raise ACISValidationError("query is required")
275
+ uid = user_id or self.user_id
276
+ if not uid:
277
+ raise ACISValidationError("user_id is required")
278
+ resolved_sid = self._resolve_session(session_id, uid, agent_id or self.agent_id)
279
+ body = {"session_id": resolved_sid, "user_id": uid, "query": query}
280
+ if model_id: body["model_id"] = model_id
281
+ if agent_id or self.agent_id: body["agent_id"] = agent_id or self.agent_id
282
+ data = self._post("/context", json=body)
283
+ raw_payload = data.get("context_payload", [])
284
+ messages = self._build_messages(raw_payload, query)
285
+ return ContextResult(messages=messages, context_payload=raw_payload, events_injected=data.get("events_injected", 0), memories_injected=data.get("memories_injected", 0), summary_injected=data.get("summary_injected", False), rules_injected=data.get("rules_injected", 0), total_tokens=data.get("total_tokens", 0), token_budget=data.get("token_budget", 0), remaining_budget=data.get("remaining_budget", 0), memory_ids_used=data.get("memory_ids_used", []))
286
+
287
+ # ─── Knowledge Search ─────────────────────────────────────────
288
+
289
+ def search(self, query: str, user_id=None, agent_id=None, top_k=10) -> list:
290
+ """Search user memories (hybrid ANN + BM25)."""
291
+ if not query:
292
+ raise ACISValidationError("query is required for search")
293
+ uid = user_id or self.user_id
294
+ if not uid:
295
+ raise ACISValidationError("user_id is required for search")
296
+ body = {"user_id": uid, "query": query, "top_k": top_k}
297
+ if agent_id or self.agent_id: body["agent_id"] = agent_id or self.agent_id
298
+ data = self._post("/search", json=body)
299
+ return [SearchResult(memory_id=r["memory_id"], content=r["content"], category=r["category"], importance=r["importance"], agent_id=r.get("agent_id"), entities=r.get("entities"), rrf_score=r.get("rrf_score")) for r in data.get("results", [])]
300
+
301
+ # ─── Memory History ───────────────────────────────────────────
302
+
303
+ def memory_history(self, memory_id: str) -> list:
304
+ """Get temporal evolution chain for a memory."""
305
+ if not memory_id:
306
+ raise ACISValidationError("memory_id is required")
307
+ data = self._get(f"/memories/{memory_id}/history")
308
+ return [MemoryHistoryEntry(memory_id=i["memory_id"], content=i["content"], category=i["category"], importance=i["importance"], created_at=i.get("created_at"), valid_to=i.get("valid_to"), superseded_by=i.get("superseded_by")) for i in data.get("chain", [])]
309
+
310
+ # ─── Feedback ─────────────────────────────────────────────────
311
+
312
+ def feedback(self, memory_ids: list, user_id=None, useful=True) -> dict:
313
+ """Submit retrieval usefulness feedback (best-effort, never raises)."""
314
+ if not memory_ids:
315
+ raise ACISValidationError("memory_ids is required")
316
+ uid = user_id or self.user_id
317
+ if not uid:
318
+ raise ACISValidationError("user_id is required for feedback")
319
+ try:
320
+ return self._post("/feedback", json={"memory_ids": memory_ids, "user_id": uid, "useful": useful})
321
+ except ACISError:
322
+ return {"status": "error", "count": 0}
323
+
324
+ # ─── GDPR ─────────────────────────────────────────────────────
325
+
326
+ def delete_user(self, user_id=None) -> dict:
327
+ """Hard-delete all user data (GDPR right-to-erasure)."""
328
+ uid = user_id or self.user_id
329
+ if not uid:
330
+ raise ACISValidationError("user_id is required")
331
+ return self._delete(f"/users/{uid}")
332
+
333
+ def export_user(self, user_id=None) -> dict:
334
+ """Export all user data (GDPR data portability)."""
335
+ uid = user_id or self.user_id
336
+ if not uid:
337
+ raise ACISValidationError("user_id is required")
338
+ return self._get(f"/users/{uid}/export")
339
+
340
+ # ─── Message Builder ──────────────────────────────────────────
341
+
342
+ def _build_messages(self, context_payload: list, query: str) -> list:
343
+ messages = []
344
+ system_parts = []
345
+ for block in context_payload:
346
+ t = block.get("type")
347
+ if t == "rules":
348
+ system_parts.append(f"[RULES]\n{block['content']}")
349
+ elif t == "profile_summary":
350
+ system_parts.append(block["content"])
351
+ elif t == "entity_graph":
352
+ system_parts.append(block["content"])
353
+ elif t == "memories":
354
+ inst = block.get("system_instruction", "")
355
+ if inst: system_parts.append(inst)
356
+ system_parts.append(block["content"])
357
+ elif t == "summary":
358
+ system_parts.append(f"[CONVERSATION SUMMARY]\n{block['content']}")
359
+ elif t == "events":
360
+ evts = block.get("content", [])
361
+ if isinstance(evts, list):
362
+ for e in evts:
363
+ if e.get("content"):
364
+ messages.append({"role": e.get("role", "user"), "content": e["content"]})
365
+ if system_parts:
366
+ messages.insert(0, {"role": "system", "content": "\n\n".join(system_parts)})
367
+ if not messages or messages[-1].get("content") != query:
368
+ messages.append({"role": "user", "content": query})
369
+ return messages
acis/exceptions.py ADDED
@@ -0,0 +1,40 @@
1
+ """ACIS SDK exceptions — typed error hierarchy."""
2
+
3
+
4
+ class ACISError(Exception):
5
+ """Base exception for all ACIS SDK errors."""
6
+
7
+ def __init__(self, message: str, code: str = "ACIS_ERROR"):
8
+ self.message = message
9
+ self.code = code
10
+ super().__init__(message)
11
+
12
+
13
+ class ACISRateLimitError(ACISError):
14
+ """Raised when a rate limit is exceeded (HTTP 429 equivalent)."""
15
+
16
+ def __init__(self, message: str, retry_after: float = 0):
17
+ self.retry_after = retry_after
18
+ super().__init__(message, code="RATE_LIMIT_EXCEEDED")
19
+
20
+
21
+ class ACISSessionNotFoundError(ACISError):
22
+ """Raised when a session does not exist."""
23
+
24
+ def __init__(self, session_id: str):
25
+ self.session_id = session_id
26
+ super().__init__(f"Session not found: {session_id}", code="SESSION_NOT_FOUND")
27
+
28
+
29
+ class ACISCircuitOpenError(ACISError):
30
+ """Raised when the write circuit breaker is open."""
31
+
32
+ def __init__(self):
33
+ super().__init__("Service temporarily unavailable (circuit open)", code="CIRCUIT_OPEN")
34
+
35
+
36
+ class ACISValidationError(ACISError):
37
+ """Raised on invalid input parameters."""
38
+
39
+ def __init__(self, message: str):
40
+ super().__init__(message, code="VALIDATION_ERROR")
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: acis-memory
3
+ Version: 1.0.0
4
+ Summary: ACIS — AI Context & Intelligent System SDK
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.25.0
8
+
9
+ # ACIS SDK
10
+
11
+ > AI Context & Intelligent System — Memory for LLM Applications
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install "git+https://oauth2:<your-gitlab-token>@git.skaleup.tech/genai-projects/chatbot/genai-hc-stm-ltm.git@UAT#subdirectory=sdk_pkg"
17
+ ```
18
+
19
+ > **Note:** Use a GitLab Personal Access Token with `read_repository` scope. Prefix with `oauth2:` as username.
20
+
21
+ ## Quick Start
22
+
23
+ ```python
24
+ from acis import MemoryClient
25
+
26
+ # Point to your ACIS deployment
27
+ client = MemoryClient(
28
+ tenant_id="your-tenant-uuid",
29
+ base_url="http://10.0.1.50:8000", # ACIS server IP
30
+ )
31
+
32
+ # Create session
33
+ session = client.create_session(user_id="user-123")
34
+
35
+ # Every turn:
36
+ client.add_event(session_id=session.session_id, role="user", content="Hello")
37
+ ctx = client.get_context(session_id=session.session_id, query="Hello")
38
+
39
+ # ctx.messages → pass directly to any LLM (OpenAI, Azure, Anthropic, etc.)
40
+ response = your_llm(messages=ctx.messages)
41
+
42
+ client.add_event(session_id=session.session_id, role="assistant", content=response)
43
+
44
+ # End conversation
45
+ client.close_session(session_id=session.session_id)
46
+ ```
47
+
48
+ ## Only Dependency
49
+
50
+ - `httpx` (HTTP client)
51
+
52
+ That's it. No PostgreSQL, Redis, Celery, or Django needed on the app server.
@@ -0,0 +1,7 @@
1
+ acis/__init__.py,sha256=E0pWCXLKPSK27Ve9Cq4tNIrj1kBTFQRVxbXkaht_S6Y,809
2
+ acis/client.py,sha256=Mo5-rYrbFdcnaXn0aqPZXe7AGn1UUDglsfTQHCaLg7I,16767
3
+ acis/exceptions.py,sha256=KjTsYZzNai1tP-md2zsxwASiRC6xzaV-Xf-MOJ8QtPM,1223
4
+ acis_memory-1.0.0.dist-info/METADATA,sha256=jkvfTeQ0_mIA5KsS0ehX2hsWzTTwdS-YXxnGSGtTug0,1406
5
+ acis_memory-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ acis_memory-1.0.0.dist-info/top_level.txt,sha256=5JZS4JbNKpVOV9r10ksTpDcGge0pB0NluBCO-O_wgQY,5
7
+ acis_memory-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ acis