clsplusplus 4.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.
Files changed (53) hide show
  1. clsplusplus/__init__.py +31 -0
  2. clsplusplus/api.py +1596 -0
  3. clsplusplus/auth.py +74 -0
  4. clsplusplus/cli.py +715 -0
  5. clsplusplus/client.py +462 -0
  6. clsplusplus/config.py +116 -0
  7. clsplusplus/cost_model.py +51 -0
  8. clsplusplus/demo_llm.py +133 -0
  9. clsplusplus/demo_llm_calls.py +100 -0
  10. clsplusplus/demo_local.py +515 -0
  11. clsplusplus/embeddings.py +52 -0
  12. clsplusplus/idempotency.py +66 -0
  13. clsplusplus/integration_service.py +256 -0
  14. clsplusplus/jwt_utils.py +39 -0
  15. clsplusplus/local_routes.py +781 -0
  16. clsplusplus/main.py +21 -0
  17. clsplusplus/memory_cycle.py +216 -0
  18. clsplusplus/memory_phase.py +3541 -0
  19. clsplusplus/memory_service.py +1323 -0
  20. clsplusplus/metrics.py +184 -0
  21. clsplusplus/middleware.py +325 -0
  22. clsplusplus/models.py +430 -0
  23. clsplusplus/permissions.py +54 -0
  24. clsplusplus/plasticity.py +148 -0
  25. clsplusplus/rate_limit.py +53 -0
  26. clsplusplus/rbac_service.py +86 -0
  27. clsplusplus/reconsolidation.py +71 -0
  28. clsplusplus/sleep_cycle.py +109 -0
  29. clsplusplus/stores/__init__.py +13 -0
  30. clsplusplus/stores/base.py +43 -0
  31. clsplusplus/stores/integration_store.py +648 -0
  32. clsplusplus/stores/l0_working_buffer.py +103 -0
  33. clsplusplus/stores/l1_indexing_store.py +427 -0
  34. clsplusplus/stores/l2_schema_graph.py +231 -0
  35. clsplusplus/stores/l3_deep_recess.py +182 -0
  36. clsplusplus/stores/l3_postgres.py +183 -0
  37. clsplusplus/stores/rbac_store.py +327 -0
  38. clsplusplus/stores/user_store.py +255 -0
  39. clsplusplus/stripe_service.py +136 -0
  40. clsplusplus/temporal.py +613 -0
  41. clsplusplus/test_suite.py +587 -0
  42. clsplusplus/tiers.py +109 -0
  43. clsplusplus/tracer.py +226 -0
  44. clsplusplus/usage.py +130 -0
  45. clsplusplus/user_embeddings.py +1636 -0
  46. clsplusplus/user_service.py +256 -0
  47. clsplusplus/webhook_dispatcher.py +229 -0
  48. clsplusplus-4.0.0.dist-info/METADATA +262 -0
  49. clsplusplus-4.0.0.dist-info/RECORD +53 -0
  50. clsplusplus-4.0.0.dist-info/WHEEL +5 -0
  51. clsplusplus-4.0.0.dist-info/entry_points.txt +2 -0
  52. clsplusplus-4.0.0.dist-info/licenses/LICENSE +201 -0
  53. clsplusplus-4.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,515 @@
1
+ """
2
+ Standalone demo API for local testing - NO Redis, NO Postgres.
3
+ Real Claude, OpenAI, Gemini only. Requires API keys in .env.
4
+ Run: uvicorn clsplusplus.demo_local:app --reload --port 8080
5
+ """
6
+
7
+ import secrets
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Optional
10
+ from uuid import uuid4
11
+
12
+ from fastapi import FastAPI, HTTPException, Path
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from pydantic import BaseModel, Field
15
+
16
+ from clsplusplus.config import Settings
17
+ from clsplusplus.memory_phase import PhaseMemoryEngine
18
+
19
+ app = FastAPI(title="CLS++ Demo (Local)", version="0.1.0")
20
+
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=["*"],
24
+ allow_credentials=False,
25
+ allow_methods=["*"],
26
+ allow_headers=["*"],
27
+ )
28
+
29
+ # =============================================================================
30
+ # In-memory stores
31
+ # =============================================================================
32
+
33
+ # Phase Memory Engine — thermodynamic Gas → Liquid memory system
34
+ # F(θ, Σ, ρ, τ) = E_prediction − Σ·S_model + λ·L_landauer
35
+ _phase_settings = Settings()
36
+ _phase_engine = PhaseMemoryEngine(
37
+ kT=_phase_settings.phase_kT,
38
+ lambda_budget=_phase_settings.phase_lambda,
39
+ tau_c1=_phase_settings.phase_tau_c1,
40
+ tau_default=_phase_settings.phase_tau_default,
41
+ tau_override=_phase_settings.phase_tau_override,
42
+ strength_floor=_phase_settings.phase_strength_floor,
43
+ capacity=_phase_settings.phase_capacity,
44
+ beta_retrieval=_phase_settings.phase_beta_retrieval,
45
+ )
46
+
47
+ # Integration store: id -> integration dict
48
+ _integrations: dict[str, dict[str, Any]] = {}
49
+
50
+ # API keys: id -> key dict
51
+ _api_keys: dict[str, dict[str, Any]] = {}
52
+
53
+ # Webhooks: id -> webhook dict
54
+ _webhooks: dict[str, dict[str, Any]] = {}
55
+
56
+
57
+ # =============================================================================
58
+ # Demo Status
59
+ # =============================================================================
60
+
61
+ @app.get("/v1/demo/status")
62
+ async def status():
63
+ s = Settings()
64
+ return {
65
+ "claude": bool(getattr(s, "anthropic_api_key", None)),
66
+ "openai": bool(getattr(s, "openai_api_key", None)),
67
+ "gemini": bool(getattr(s, "google_api_key", None)),
68
+ }
69
+
70
+
71
+ # =============================================================================
72
+ # Integration Management (in-memory, no PostgreSQL)
73
+ # =============================================================================
74
+
75
+ class IntegrationCreateReq(BaseModel):
76
+ name: str = Field(..., min_length=1, max_length=128)
77
+ description: str = Field(default="", max_length=1024)
78
+ namespace: str = Field(default="default", min_length=1, max_length=64)
79
+ owner_email: Optional[str] = Field(default=None, max_length=256)
80
+ metadata: dict[str, Any] = Field(default_factory=dict)
81
+
82
+
83
+ class WebhookCreateReq(BaseModel):
84
+ url: str = Field(..., min_length=10, max_length=2048)
85
+ events: list[str] = Field(default=["*"], max_length=50)
86
+ description: str = Field(default="", max_length=1024)
87
+ namespace_filter: Optional[str] = Field(default=None, max_length=64)
88
+
89
+
90
+ def _generate_api_key() -> str:
91
+ return "cls_live_" + secrets.token_hex(16)
92
+
93
+
94
+ def _generate_webhook_secret() -> str:
95
+ return "whsec_" + secrets.token_hex(20)
96
+
97
+
98
+ @app.post("/v1/integrations")
99
+ async def create_integration(req: IntegrationCreateReq):
100
+ integration_id = str(uuid4())
101
+ now = datetime.now(timezone.utc).isoformat()
102
+
103
+ integration = {
104
+ "id": integration_id,
105
+ "name": req.name,
106
+ "description": req.description,
107
+ "namespace": req.namespace,
108
+ "owner_email": req.owner_email,
109
+ "metadata": req.metadata,
110
+ "status": "active",
111
+ "created_at": now,
112
+ }
113
+ _integrations[integration_id] = integration
114
+
115
+ # Auto-create first API key
116
+ api_key = _generate_api_key()
117
+ key_id = str(uuid4())
118
+ key_record = {
119
+ "id": key_id,
120
+ "integration_id": integration_id,
121
+ "key": api_key,
122
+ "key_masked": api_key[:12] + "..." + api_key[-4:],
123
+ "label": "default",
124
+ "scopes": ["memory:read", "memory:write"],
125
+ "created_at": now,
126
+ }
127
+ _api_keys[key_id] = key_record
128
+
129
+ return {
130
+ "integration": integration,
131
+ "api_key": key_record,
132
+ "_hint": "Save your API key now - it won't be shown again.",
133
+ }
134
+
135
+
136
+ @app.get("/v1/integrations")
137
+ async def list_integrations(namespace: str = "default"):
138
+ items = [i for i in _integrations.values() if i["namespace"] == namespace]
139
+ return {"integrations": items}
140
+
141
+
142
+ @app.get("/v1/integrations/{integration_id}")
143
+ async def get_integration(integration_id: str = Path(...)):
144
+ integration = _integrations.get(integration_id)
145
+ if not integration:
146
+ raise HTTPException(status_code=404, detail="Integration not found")
147
+ return integration
148
+
149
+
150
+ @app.delete("/v1/integrations/{integration_id}")
151
+ async def delete_integration(integration_id: str = Path(...)):
152
+ if integration_id not in _integrations:
153
+ raise HTTPException(status_code=404, detail="Integration not found")
154
+ del _integrations[integration_id]
155
+ return {"deleted": True, "integration_id": integration_id}
156
+
157
+
158
+ # --- Webhooks ---
159
+
160
+ @app.post("/v1/integrations/{integration_id}/webhooks")
161
+ async def create_webhook(req: WebhookCreateReq, integration_id: str = Path(...)):
162
+ if integration_id not in _integrations:
163
+ raise HTTPException(status_code=404, detail="Integration not found")
164
+
165
+ webhook_id = str(uuid4())
166
+ secret = _generate_webhook_secret()
167
+ now = datetime.now(timezone.utc).isoformat()
168
+
169
+ webhook = {
170
+ "id": webhook_id,
171
+ "integration_id": integration_id,
172
+ "url": req.url,
173
+ "events": req.events,
174
+ "secret": secret,
175
+ "description": req.description,
176
+ "namespace_filter": req.namespace_filter,
177
+ "status": "active",
178
+ "created_at": now,
179
+ }
180
+ _webhooks[webhook_id] = webhook
181
+
182
+ return {
183
+ "webhook": webhook,
184
+ "_hint": "Save your webhook signing secret now - it won't be shown again.",
185
+ }
186
+
187
+
188
+ @app.get("/v1/integrations/{integration_id}/webhooks")
189
+ async def list_webhooks(integration_id: str = Path(...)):
190
+ items = [w for w in _webhooks.values() if w["integration_id"] == integration_id]
191
+ return {"webhooks": items}
192
+
193
+
194
+ @app.delete("/v1/integrations/{integration_id}/webhooks/{webhook_id}")
195
+ async def delete_webhook(integration_id: str = Path(...), webhook_id: str = Path(...)):
196
+ if webhook_id not in _webhooks:
197
+ raise HTTPException(status_code=404, detail="Webhook not found")
198
+ del _webhooks[webhook_id]
199
+ return {"deleted": True, "webhook_id": webhook_id}
200
+
201
+
202
+ # =============================================================================
203
+ # Memory Cycle (uses Phase Engine exclusively)
204
+ # =============================================================================
205
+
206
+ class MemoryCycleReq(BaseModel):
207
+ statements: list[str] = Field(..., min_length=1, max_length=20)
208
+ queries: list[str] = Field(..., min_length=1, max_length=10)
209
+ models: list[str] = Field(default=["claude", "openai"], max_length=3)
210
+ namespace: str = Field(default="cycle-test", min_length=1, max_length=64)
211
+
212
+
213
+ @app.post("/v1/demo/memory-cycle")
214
+ async def memory_cycle(req: MemoryCycleReq):
215
+ for m in req.models:
216
+ if m not in ("claude", "openai", "gemini"):
217
+ raise HTTPException(status_code=400, detail=f"Invalid model: {m}")
218
+
219
+ settings = Settings()
220
+ cycle_id = str(uuid4())
221
+
222
+ # Phase 1: ENCODE — store through the thermodynamic engine (zero LLM)
223
+ encode_items = []
224
+ for stmt in req.statements:
225
+ item = _phase_engine.store(stmt, req.namespace)
226
+ if item:
227
+ encode_items.append({
228
+ "id": item.id,
229
+ "text": item.fact.raw_text,
230
+ "strength": round(item.consolidation_strength, 4),
231
+ "phase": "liquid" if item.consolidation_strength >= _phase_engine.STRENGTH_FLOOR else "gas",
232
+ })
233
+
234
+ # Phase 2: RETRIEVE — free-energy-ranked search
235
+ retrieve_results = []
236
+ for query in req.queries:
237
+ results = _phase_engine.search(query, req.namespace, limit=10)
238
+ retrieve_results.append({
239
+ "query": query,
240
+ "found": len(results),
241
+ "items": [{"id": item.id, "text": item.fact.raw_text,
242
+ "strength": round(item.consolidation_strength, 4),
243
+ "score": round(score, 4)}
244
+ for score, item in results[:5]],
245
+ })
246
+
247
+ total_found = sum(r["found"] for r in retrieve_results)
248
+
249
+ # Phase 3: AUGMENT (real LLM calls with phase-engine context)
250
+ augment_results = {}
251
+ from clsplusplus.demo_llm_calls import call_claude, call_openai, call_gemini
252
+ callers = {"claude": call_claude, "openai": call_openai, "gemini": call_gemini}
253
+
254
+ for model in req.models:
255
+ model_results = []
256
+ for query in req.queries[:2]:
257
+ memory_context, debug_items = _phase_engine.build_augmented_context(
258
+ query, req.namespace, limit=5
259
+ )
260
+ system_prompt = f"You are a helpful assistant.\n\n{memory_context}\n\nAnswer naturally."
261
+ try:
262
+ reply = await callers[model](settings, system_prompt, query)
263
+ model_results.append({
264
+ "query": query, "response": reply,
265
+ "memory_context_items": len(debug_items), "memory_used": len(debug_items) > 0,
266
+ })
267
+ except Exception as e:
268
+ model_results.append({"query": query, "error": str(e), "memory_used": False})
269
+ augment_results[model] = model_results
270
+
271
+ # Verdict
272
+ encode_ok = len(encode_items) > 0
273
+ retrieve_ok = total_found > 0
274
+ augment_ok = any(
275
+ any(r.get("memory_used", False) for r in results)
276
+ for results in augment_results.values()
277
+ )
278
+
279
+ verdict = "PASS" if (encode_ok and retrieve_ok and augment_ok) else (
280
+ "PARTIAL" if (encode_ok and retrieve_ok) else "FAIL"
281
+ )
282
+
283
+ return {
284
+ "cycle_id": cycle_id,
285
+ "namespace": req.namespace,
286
+ "models": req.models,
287
+ "phases": {
288
+ "encode": {"stored": len(encode_items), "total": len(req.statements), "items": encode_items},
289
+ "retrieve": {"queries": len(req.queries), "total_found": total_found, "results": retrieve_results},
290
+ "augment": augment_results,
291
+ },
292
+ "verdict": verdict,
293
+ "phase_debug": _phase_engine.get_phase_debug(req.namespace),
294
+ }
295
+
296
+
297
+ # =============================================================================
298
+ # Chat Sessions + LLM Routing Layer
299
+ # =============================================================================
300
+
301
+ # Session store: session_id -> { id, name, namespace, created_at, messages }
302
+ _sessions: dict[str, dict[str, Any]] = {}
303
+
304
+ # LLM priority for automatic routing with failover
305
+ _LLM_PRIORITY = ["openai", "claude", "gemini"]
306
+
307
+ _ERROR_MARKERS = [
308
+ "An error occurred",
309
+ "Add CLS_ANTHROPIC_API_KEY",
310
+ "Add CLS_OPENAI_API_KEY",
311
+ "Add CLS_GOOGLE_API_KEY",
312
+ "No response",
313
+ "content may have been blocked",
314
+ "credit balance is too low",
315
+ ]
316
+
317
+
318
+ def _is_error_reply(reply: str) -> bool:
319
+ return any(marker in reply for marker in _ERROR_MARKERS)
320
+
321
+
322
+ async def _route_to_llm(settings: Settings, system: str, user_msg: str) -> tuple[str, str]:
323
+ """Try each LLM in priority order. First success wins. User never knows.
324
+ Returns (reply, model_used)."""
325
+ from clsplusplus.demo_llm_calls import call_claude, call_openai, call_gemini
326
+
327
+ callers = {"claude": call_claude, "openai": call_openai, "gemini": call_gemini}
328
+ last_reply = ""
329
+
330
+ for model_name in _LLM_PRIORITY:
331
+ try:
332
+ reply = await callers[model_name](settings, system, user_msg)
333
+ if not _is_error_reply(reply):
334
+ return reply, model_name
335
+ last_reply = reply
336
+ except Exception:
337
+ continue
338
+
339
+ # All failed
340
+ if last_reply:
341
+ return last_reply, "unknown"
342
+ return "I'm having trouble connecting right now. Please try again in a moment.", "none"
343
+
344
+
345
+ class SessionMessageReq(BaseModel):
346
+ message: str = Field(..., min_length=1, max_length=4096)
347
+
348
+
349
+ # Global memory namespace — shared across ALL sessions for the same user.
350
+ # Conversation history stays per-session; memory is cross-session.
351
+ _GLOBAL_NS = "global"
352
+
353
+
354
+ @app.post("/v1/chat/sessions")
355
+ async def create_session():
356
+ session_id = str(uuid4())
357
+ short_id = session_id[:4]
358
+ now = datetime.now(timezone.utc).isoformat()
359
+
360
+ session = {
361
+ "id": session_id,
362
+ "name": f"Chat {short_id}",
363
+ "namespace": _GLOBAL_NS,
364
+ "created_at": now,
365
+ "messages": [],
366
+ }
367
+ _sessions[session_id] = session
368
+ return {"session_id": session_id, "name": session["name"], "namespace": _GLOBAL_NS, "created_at": now}
369
+
370
+
371
+ @app.get("/v1/chat/sessions")
372
+ async def list_sessions():
373
+ sessions = sorted(_sessions.values(), key=lambda s: s["created_at"], reverse=True)
374
+ return {
375
+ "sessions": [
376
+ {"session_id": s["id"], "name": s["name"], "namespace": s["namespace"],
377
+ "created_at": s["created_at"], "message_count": len(s["messages"])}
378
+ for s in sessions
379
+ ]
380
+ }
381
+
382
+
383
+ @app.get("/v1/chat/sessions/{session_id}")
384
+ async def get_session(session_id: str = Path(...)):
385
+ session = _sessions.get(session_id)
386
+ if not session:
387
+ raise HTTPException(status_code=404, detail="Session not found")
388
+ return {
389
+ "session_id": session["id"],
390
+ "name": session["name"],
391
+ "namespace": session["namespace"],
392
+ "created_at": session["created_at"],
393
+ "messages": session["messages"],
394
+ }
395
+
396
+
397
+ @app.delete("/v1/chat/sessions/{session_id}")
398
+ async def delete_session(session_id: str = Path(...)):
399
+ if session_id not in _sessions:
400
+ raise HTTPException(status_code=404, detail="Session not found")
401
+ # Don't wipe global memory when deleting a session — memory persists across sessions
402
+ del _sessions[session_id]
403
+ return {"deleted": True, "session_id": session_id}
404
+
405
+
406
+ @app.post("/v1/chat/sessions/{session_id}/message")
407
+ async def send_message(req: SessionMessageReq, session_id: str = Path(...)):
408
+ session = _sessions.get(session_id)
409
+ if not session:
410
+ raise HTTPException(status_code=404, detail="Session not found")
411
+
412
+ user_msg = req.message.strip()
413
+ now = datetime.now(timezone.utc).isoformat()
414
+ settings = Settings()
415
+
416
+ # 1. Store user message in session conversation history
417
+ session["messages"].append({"role": "user", "content": user_msg, "timestamp": now})
418
+
419
+ # 2. PHASE ENGINE: Store message through the thermodynamic engine (zero LLM).
420
+ # The engine tokenizes, indexes, detects contradictions, and manages
421
+ # thermodynamic state entirely on its own. No external calls.
422
+ # Surprise (Σ) is computed as KL divergence against existing beliefs.
423
+ # Contradicted memories receive irreversible surprise damage.
424
+ _phase_engine.store(user_msg, _GLOBAL_NS)
425
+
426
+ # 3. PHASE ENGINE: Retrieve via free energy ranking.
427
+ # Score = -F(item) × relevance(query, item)
428
+ # Items below strength_floor (gas phase) are excluded.
429
+ # No "NEWEST FIRST" hack — the physics handles conflict resolution.
430
+ # NOTE: Only call build_augmented_context (which calls search() internally).
431
+ # Do NOT call search() separately — that would double-count retrieval.
432
+ memory_context, phase_debug_items = _phase_engine.build_augmented_context(
433
+ user_msg, _GLOBAL_NS, limit=5
434
+ )
435
+ phase_results = phase_debug_items # Already computed by build_augmented_context
436
+
437
+ # 4. Build conversation history for THIS session only (last 20 messages)
438
+ recent_messages = session["messages"][-20:]
439
+ convo_lines = []
440
+ for m in recent_messages[:-1]: # Exclude the message we just added
441
+ role_label = "User" if m["role"] == "user" else "Assistant"
442
+ convo_lines.append(f"{role_label}: {m['content']}")
443
+ conversation_history = "\n".join(convo_lines) if convo_lines else ""
444
+
445
+ # 5. Build augmented system prompt — physics-driven, no hacks
446
+ system_parts = [
447
+ "You are a helpful, friendly assistant. Chat naturally and conversationally.",
448
+ ]
449
+ if conversation_history:
450
+ system_parts.append(f"Recent conversation in this chat:\n{conversation_history}")
451
+ if phase_results:
452
+ system_parts.append(memory_context)
453
+ system_parts.append(
454
+ "Use the strongest-recalled memories as ground truth. "
455
+ "Respond naturally. Never mention 'memory' or 'context' explicitly."
456
+ )
457
+ system = "\n\n".join(system_parts)
458
+
459
+ # 6. Route to LLM with automatic failover
460
+ reply, model_used = await _route_to_llm(settings, system, user_msg)
461
+
462
+ # 7. Store AI response in session history (NOT in phase memory — only user facts)
463
+ reply_ts = datetime.now(timezone.utc).isoformat()
464
+ memory_used = len(phase_results) > 0
465
+ session["messages"].append({
466
+ "role": "assistant", "content": reply, "timestamp": reply_ts,
467
+ "memory_used": memory_used, "memory_count": len(phase_results),
468
+ })
469
+
470
+ # 8. Return response + full thermodynamic debug info
471
+ phase_debug = _phase_engine.get_phase_debug(_GLOBAL_NS)
472
+
473
+ return {
474
+ "reply": reply,
475
+ "memory_used": memory_used,
476
+ "memory_count": len(phase_results),
477
+ "debug": {
478
+ "model_used": model_used,
479
+ "augmented_prompt": system,
480
+ "user_message": user_msg,
481
+ "memory_searched": [d["text"] for d in phase_debug_items],
482
+ "conversation_history_lines": len(convo_lines),
483
+ "memory_store": phase_debug.get("items", []),
484
+ "phase_dynamics": phase_debug,
485
+ },
486
+ }
487
+
488
+
489
+ # =============================================================================
490
+ # Health
491
+ # =============================================================================
492
+
493
+ @app.get("/v1/memory/health")
494
+ async def health():
495
+ return {
496
+ "status": "healthy",
497
+ "stores": {
498
+ "L0": {"status": "healthy", "store": "in-memory"},
499
+ "L1": {"status": "healthy", "store": "in-memory"},
500
+ "L2": {"status": "healthy", "store": "in-memory"},
501
+ "L3": {"status": "healthy", "store": "in-memory"},
502
+ },
503
+ "mode": "demo-local",
504
+ }
505
+
506
+
507
+ @app.get("/")
508
+ async def root():
509
+ return {
510
+ "name": "CLS++ Demo API (Local)",
511
+ "version": "0.1.0",
512
+ "mode": "demo-local (in-memory, no Redis/Postgres)",
513
+ "docs": "/docs",
514
+ "health": "/v1/memory/health",
515
+ }
@@ -0,0 +1,52 @@
1
+ """Embedding service for CLS++."""
2
+
3
+ from typing import Optional
4
+
5
+ import numpy as np
6
+
7
+ from clsplusplus.config import Settings
8
+ from clsplusplus.models import MemoryItem
9
+
10
+
11
+ class EmbeddingService:
12
+ """Produces embeddings for memory items."""
13
+
14
+ def __init__(self, settings: Optional[Settings] = None):
15
+ self.settings = settings or Settings()
16
+ self._model = None
17
+
18
+ @property
19
+ def model(self):
20
+ """Lazy load the embedding model."""
21
+ if self._model is None:
22
+ try:
23
+ from sentence_transformers import SentenceTransformer
24
+ self._model = SentenceTransformer(self.settings.embedding_model)
25
+ except ImportError:
26
+ self._model = False # sentinel: unavailable
27
+ return self._model
28
+
29
+ def embed(self, text: str) -> list[float]:
30
+ """Embed a single text. Returns empty list if model unavailable."""
31
+ if self.model is False:
32
+ return []
33
+ return self.model.encode(text, convert_to_numpy=True).tolist()
34
+
35
+ def embed_batch(self, texts: list[str]) -> list[list[float]]:
36
+ """Embed multiple texts. Returns empty lists if model unavailable."""
37
+ if self.model is False:
38
+ return [[] for _ in texts]
39
+ return self.model.encode(texts, convert_to_numpy=True).tolist()
40
+
41
+ def embed_item(self, item: MemoryItem) -> MemoryItem:
42
+ """Add embedding to a memory item."""
43
+ if not item.embedding:
44
+ item.embedding = self.embed(item.text)
45
+ return item
46
+
47
+ @staticmethod
48
+ def cosine_similarity(a: list[float], b: list[float]) -> float:
49
+ """Compute cosine similarity between two vectors."""
50
+ va = np.array(a)
51
+ vb = np.array(b)
52
+ return float(np.dot(va, vb) / (np.linalg.norm(va) * np.linalg.norm(vb) + 1e-9))
@@ -0,0 +1,66 @@
1
+ """CLS++ idempotency - prevent duplicate operations from retries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import Any, Optional
8
+
9
+ from clsplusplus.config import Settings
10
+
11
+ _redis_client_cache: dict[str, object] = {}
12
+
13
+
14
+ def _redis_client(redis_url: str):
15
+ import redis.asyncio as redis
16
+ if redis_url not in _redis_client_cache:
17
+ _redis_client_cache[redis_url] = redis.from_url(redis_url, decode_responses=True)
18
+ return _redis_client_cache[redis_url]
19
+
20
+
21
+ def _cache_key(key: str, method: str, path: str, body: bytes) -> str:
22
+ h = hashlib.sha256(f"{method}:{path}:{body}".encode()).hexdigest()
23
+ return f"cls:idempotency:{key}:{h[:32]}"
24
+
25
+
26
+ async def get_cached_response(
27
+ idempotency_key: str,
28
+ method: str,
29
+ path: str,
30
+ body: bytes,
31
+ settings: Optional[Settings] = None,
32
+ ) -> Optional[dict[str, Any]]:
33
+ """Return cached response if idempotent request was already processed."""
34
+ settings = settings or Settings()
35
+ try:
36
+ client = _redis_client(settings.redis_url)
37
+ ckey = _cache_key(idempotency_key, method, path, body)
38
+ raw = await client.get(ckey)
39
+ if raw:
40
+ return json.loads(raw)
41
+ except Exception:
42
+ pass
43
+ return None
44
+
45
+
46
+ async def cache_response(
47
+ idempotency_key: str,
48
+ method: str,
49
+ path: str,
50
+ body: bytes,
51
+ status: int,
52
+ response_body: dict,
53
+ settings: Optional[Settings] = None,
54
+ ) -> None:
55
+ """Cache response for idempotent request."""
56
+ settings = settings or Settings()
57
+ try:
58
+ client = _redis_client(settings.redis_url)
59
+ ckey = _cache_key(idempotency_key, method, path, body)
60
+ await client.setex(
61
+ ckey,
62
+ settings.idempotency_ttl_seconds,
63
+ json.dumps({"status": status, "body": response_body}),
64
+ )
65
+ except Exception:
66
+ pass