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.
- clsplusplus/__init__.py +31 -0
- clsplusplus/api.py +1596 -0
- clsplusplus/auth.py +74 -0
- clsplusplus/cli.py +715 -0
- clsplusplus/client.py +462 -0
- clsplusplus/config.py +116 -0
- clsplusplus/cost_model.py +51 -0
- clsplusplus/demo_llm.py +133 -0
- clsplusplus/demo_llm_calls.py +100 -0
- clsplusplus/demo_local.py +515 -0
- clsplusplus/embeddings.py +52 -0
- clsplusplus/idempotency.py +66 -0
- clsplusplus/integration_service.py +256 -0
- clsplusplus/jwt_utils.py +39 -0
- clsplusplus/local_routes.py +781 -0
- clsplusplus/main.py +21 -0
- clsplusplus/memory_cycle.py +216 -0
- clsplusplus/memory_phase.py +3541 -0
- clsplusplus/memory_service.py +1323 -0
- clsplusplus/metrics.py +184 -0
- clsplusplus/middleware.py +325 -0
- clsplusplus/models.py +430 -0
- clsplusplus/permissions.py +54 -0
- clsplusplus/plasticity.py +148 -0
- clsplusplus/rate_limit.py +53 -0
- clsplusplus/rbac_service.py +86 -0
- clsplusplus/reconsolidation.py +71 -0
- clsplusplus/sleep_cycle.py +109 -0
- clsplusplus/stores/__init__.py +13 -0
- clsplusplus/stores/base.py +43 -0
- clsplusplus/stores/integration_store.py +648 -0
- clsplusplus/stores/l0_working_buffer.py +103 -0
- clsplusplus/stores/l1_indexing_store.py +427 -0
- clsplusplus/stores/l2_schema_graph.py +231 -0
- clsplusplus/stores/l3_deep_recess.py +182 -0
- clsplusplus/stores/l3_postgres.py +183 -0
- clsplusplus/stores/rbac_store.py +327 -0
- clsplusplus/stores/user_store.py +255 -0
- clsplusplus/stripe_service.py +136 -0
- clsplusplus/temporal.py +613 -0
- clsplusplus/test_suite.py +587 -0
- clsplusplus/tiers.py +109 -0
- clsplusplus/tracer.py +226 -0
- clsplusplus/usage.py +130 -0
- clsplusplus/user_embeddings.py +1636 -0
- clsplusplus/user_service.py +256 -0
- clsplusplus/webhook_dispatcher.py +229 -0
- clsplusplus-4.0.0.dist-info/METADATA +262 -0
- clsplusplus-4.0.0.dist-info/RECORD +53 -0
- clsplusplus-4.0.0.dist-info/WHEEL +5 -0
- clsplusplus-4.0.0.dist-info/entry_points.txt +2 -0
- clsplusplus-4.0.0.dist-info/licenses/LICENSE +201 -0
- 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
|