ColettoG commited on
Commit
bb256eb
·
1 Parent(s): af914d1

add: database integration

Browse files
requirements.txt CHANGED
@@ -43,6 +43,7 @@ requests>=2.31.0
43
  # Data validation and serialization
44
  marshmallow>=3.20.0
45
  jsonschema>=4.19.0
 
46
 
47
  # Async support
48
  asyncio-mqtt>=0.16.0
@@ -61,4 +62,4 @@ mypy>=1.5.0
61
  clickhouse-connect>=0.7.0
62
  clickhouse-sqlalchemy==0.3.2
63
 
64
- langchain-tavily>=0.2.11
 
43
  # Data validation and serialization
44
  marshmallow>=3.20.0
45
  jsonschema>=4.19.0
46
+ PyJWT>=2.8.0
47
 
48
  # Async support
49
  asyncio-mqtt>=0.16.0
 
62
  clickhouse-connect>=0.7.0
63
  clickhouse-sqlalchemy==0.3.2
64
 
65
+ langchain-tavily>=0.2.11
src/agents/swap/storage.py CHANGED
@@ -1,45 +1,66 @@
1
- """Persistent storage for swap intents and metadata."""
2
  from __future__ import annotations
3
 
4
  import copy
5
- import json
6
- import os
7
  import time
8
  from datetime import datetime, timezone
9
- from pathlib import Path
10
  from threading import Lock
11
  from typing import Any, Dict, List, Optional
12
 
13
- _STATE_TEMPLATE: Dict[str, Dict[str, Any]] = {
14
- "intents": {},
15
- "metadata": {},
16
- "history": {},
17
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
  class SwapStateRepository:
21
- """File-backed storage for swap agent state with TTL and history support."""
22
 
23
  _instance: "SwapStateRepository" | None = None
24
  _instance_lock: Lock = Lock()
25
 
26
  def __init__(
27
  self,
28
- path: Optional[Path] = None,
29
- ttl_seconds: int = 3600,
 
30
  history_limit: int = 10,
31
  ) -> None:
32
- env_path = os.getenv("SWAP_STATE_PATH")
33
- default_path = Path(__file__).with_name("swap_state.json")
34
- self._path = Path(path or env_path or default_path)
35
- self._ttl_seconds = ttl_seconds
36
  self._history_limit = history_limit
37
- self._lock = Lock()
38
- self._state: Dict[str, Dict[str, Any]] = copy.deepcopy(_STATE_TEMPLATE)
39
- self._ensure_parent()
40
- with self._lock:
41
- self._load_locked()
 
 
 
 
 
42
 
 
43
  @classmethod
44
  def instance(cls) -> "SwapStateRepository":
45
  if cls._instance is None:
@@ -53,93 +74,19 @@ class SwapStateRepository:
53
  with cls._instance_lock:
54
  cls._instance = None
55
 
56
- def _ensure_parent(self) -> None:
57
- try:
58
- self._path.parent.mkdir(parents=True, exist_ok=True)
59
- except OSError:
60
- pass
61
-
62
- def _load_locked(self) -> None:
63
- if not self._path.exists():
64
- self._state = copy.deepcopy(_STATE_TEMPLATE)
65
- return
66
- try:
67
- data = json.loads(self._path.read_text())
68
- except Exception:
69
- self._state = copy.deepcopy(_STATE_TEMPLATE)
70
- return
71
- if not isinstance(data, dict):
72
- self._state = copy.deepcopy(_STATE_TEMPLATE)
73
- return
74
- state = copy.deepcopy(_STATE_TEMPLATE)
75
- for key in state:
76
- if isinstance(data.get(key), dict):
77
- state[key] = data[key]
78
- self._state = state
79
-
80
- def _persist_locked(self) -> None:
81
- self._ensure_parent()
82
- tmp_path = self._path.with_suffix(".tmp")
83
- payload = json.dumps(self._state, ensure_ascii=False, indent=2)
84
- try:
85
- tmp_path.write_text(payload)
86
- tmp_path.replace(self._path)
87
- except Exception:
88
- pass
89
-
90
- @staticmethod
91
- def _normalize_identifier(value: Optional[str], label: str) -> str:
92
- candidate = (value or "").strip()
93
- if not candidate:
94
- raise ValueError(f"{label} is required for swap state operations.")
95
- return candidate
96
-
97
- def _key(self, user_id: Optional[str], conversation_id: Optional[str]) -> str:
98
- user = self._normalize_identifier(user_id, "user_id")
99
- conversation = self._normalize_identifier(conversation_id, "conversation_id")
100
- return f"{user}::{conversation}"
101
-
102
- def _purge_locked(self) -> None:
103
- if self._ttl_seconds <= 0:
104
- return
105
- cutoff = time.time() - self._ttl_seconds
106
- intents = self._state["intents"]
107
- metadata = self._state["metadata"]
108
- stale_keys = [
109
- key for key, record in intents.items() if record.get("updated_at", 0) < cutoff
110
- ]
111
- for key in stale_keys:
112
- intents.pop(key, None)
113
- metadata.pop(key, None)
114
-
115
- @staticmethod
116
- def _format_timestamp(value: float) -> str:
117
- return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
118
-
119
- def _history_unlocked(self, key: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
120
- history = self._state["history"].get(key, [])
121
- sorted_history = sorted(history, key=lambda item: item.get("timestamp", 0), reverse=True)
122
- effective_limit = limit or self._history_limit
123
- if effective_limit:
124
- sorted_history = sorted_history[:effective_limit]
125
- results: List[Dict[str, Any]] = []
126
- for item in sorted_history:
127
- entry = copy.deepcopy(item)
128
- ts = entry.get("timestamp")
129
- if ts is not None:
130
- entry["timestamp"] = self._format_timestamp(float(ts))
131
- results.append(entry)
132
- return results
133
-
134
  def load_intent(self, user_id: str, conversation_id: str) -> Optional[Dict[str, Any]]:
135
- key = self._key(user_id, conversation_id)
136
- with self._lock:
137
- self._purge_locked()
138
- record = self._state["intents"].get(key)
139
  if not record:
140
  return None
141
  return copy.deepcopy(record.get("intent"))
142
 
 
 
 
 
 
143
  def persist_intent(
144
  self,
145
  user_id: str,
@@ -149,80 +96,230 @@ class SwapStateRepository:
149
  done: bool,
150
  summary: Optional[Dict[str, Any]] = None,
151
  ) -> List[Dict[str, Any]]:
152
- key = self._key(user_id, conversation_id)
153
- with self._lock:
154
- self._purge_locked()
155
  now = time.time()
156
  if done:
157
  self._state["intents"].pop(key, None)
158
  else:
159
- self._state["intents"][key] = {
160
- "intent": copy.deepcopy(intent),
161
- "updated_at": now,
162
- }
163
  if metadata:
164
  meta_copy = copy.deepcopy(metadata)
165
  meta_copy["updated_at"] = now
166
  self._state["metadata"][key] = meta_copy
167
  if done and summary:
168
- history = self._state["history"].get(key, [])
169
  summary_copy = copy.deepcopy(summary)
170
  summary_copy.setdefault("timestamp", now)
171
  history.append(summary_copy)
172
- self._state["history"][key] = history[-self._history_limit:]
173
- try:
174
- self._persist_locked()
175
- except Exception:
176
- pass
177
- return self._history_unlocked(key)
178
-
179
- def set_metadata(self, user_id: str, conversation_id: str, metadata: Dict[str, Any]) -> None:
180
- key = self._key(user_id, conversation_id)
181
- with self._lock:
182
- self._purge_locked()
 
 
 
 
 
 
 
 
 
183
  if metadata:
184
  meta_copy = copy.deepcopy(metadata)
185
  meta_copy["updated_at"] = time.time()
186
  self._state["metadata"][key] = meta_copy
187
  else:
188
  self._state["metadata"].pop(key, None)
189
- try:
190
- self._persist_locked()
191
- except Exception:
192
- pass
 
 
 
 
 
 
193
 
194
  def clear_metadata(self, user_id: str, conversation_id: str) -> None:
195
  self.set_metadata(user_id, conversation_id, {})
196
 
197
  def clear_intent(self, user_id: str, conversation_id: str) -> None:
198
- key = self._key(user_id, conversation_id)
199
- with self._lock:
200
- self._state["intents"].pop(key, None)
201
- try:
202
- self._persist_locked()
203
- except Exception:
204
- pass
205
 
206
  def get_metadata(self, user_id: str, conversation_id: str) -> Dict[str, Any]:
207
- key = self._key(user_id, conversation_id)
208
- with self._lock:
209
- self._purge_locked()
210
- meta = self._state["metadata"].get(key)
211
- if not meta:
212
  return {}
213
- entry = copy.deepcopy(meta)
214
  ts = entry.pop("updated_at", None)
215
  if ts is not None:
216
- entry["updated_at"] = self._format_timestamp(float(ts))
217
  return entry
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  def get_history(
220
  self,
221
  user_id: str,
222
  conversation_id: str,
223
  limit: Optional[int] = None,
224
  ) -> List[Dict[str, Any]]:
225
- key = self._key(user_id, conversation_id)
226
- with self._lock:
227
- return self._history_unlocked(key, limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gateway-backed storage for swap intents with local fallback."""
2
  from __future__ import annotations
3
 
4
  import copy
 
 
5
  import time
6
  from datetime import datetime, timezone
 
7
  from threading import Lock
8
  from typing import Any, Dict, List, Optional
9
 
10
+ from src.integrations.panorama_gateway import (
11
+ PanoramaGatewayClient,
12
+ PanoramaGatewayError,
13
+ PanoramaGatewaySettings,
14
+ get_panorama_settings,
15
+ )
16
+
17
+ SWAP_SESSION_ENTITY = "swap-sessions"
18
+ SWAP_HISTORY_ENTITY = "swap-histories"
19
+
20
+
21
+ def _utc_now_iso() -> str:
22
+ return datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()
23
+
24
+
25
+ def _identifier(user_id: str, conversation_id: str) -> str:
26
+ return f"{user_id}:{conversation_id}"
27
+
28
+
29
+ def _as_float(value: Any) -> Optional[float]:
30
+ if value is None:
31
+ return None
32
+ try:
33
+ return float(value)
34
+ except (TypeError, ValueError):
35
+ return None
36
 
37
 
38
  class SwapStateRepository:
39
+ """Stores swap agent state via Panorama's gateway or an in-memory fallback."""
40
 
41
  _instance: "SwapStateRepository" | None = None
42
  _instance_lock: Lock = Lock()
43
 
44
  def __init__(
45
  self,
46
+ *,
47
+ client: PanoramaGatewayClient | None = None,
48
+ settings: PanoramaGatewaySettings | None = None,
49
  history_limit: int = 10,
50
  ) -> None:
 
 
 
 
51
  self._history_limit = history_limit
52
+ try:
53
+ self._settings = settings or get_panorama_settings()
54
+ self._client = client or PanoramaGatewayClient(self._settings)
55
+ self._use_gateway = True
56
+ except ValueError:
57
+ # PANORAMA_GATEWAY_URL or JWT secrets not configured – fall back to local store.
58
+ self._settings = None
59
+ self._client = None
60
+ self._use_gateway = False
61
+ self._state = {"intents": {}, "metadata": {}, "history": {}}
62
 
63
+ # ---- Singleton helpers -----------------------------------------------
64
  @classmethod
65
  def instance(cls) -> "SwapStateRepository":
66
  if cls._instance is None:
 
74
  with cls._instance_lock:
75
  cls._instance = None
76
 
77
+ # ---- Core API ---------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def load_intent(self, user_id: str, conversation_id: str) -> Optional[Dict[str, Any]]:
79
+ if not self._use_gateway:
80
+ record = self._state["intents"].get(_identifier(user_id, conversation_id))
 
 
81
  if not record:
82
  return None
83
  return copy.deepcopy(record.get("intent"))
84
 
85
+ session = self._get_session(user_id, conversation_id)
86
+ if not session:
87
+ return None
88
+ return session.get("intent") or None
89
+
90
  def persist_intent(
91
  self,
92
  user_id: str,
 
96
  done: bool,
97
  summary: Optional[Dict[str, Any]] = None,
98
  ) -> List[Dict[str, Any]]:
99
+ if not self._use_gateway:
100
+ key = _identifier(user_id, conversation_id)
 
101
  now = time.time()
102
  if done:
103
  self._state["intents"].pop(key, None)
104
  else:
105
+ self._state["intents"][key] = {"intent": copy.deepcopy(intent), "updated_at": now}
 
 
 
106
  if metadata:
107
  meta_copy = copy.deepcopy(metadata)
108
  meta_copy["updated_at"] = now
109
  self._state["metadata"][key] = meta_copy
110
  if done and summary:
111
+ history = self._state["history"].setdefault(key, [])
112
  summary_copy = copy.deepcopy(summary)
113
  summary_copy.setdefault("timestamp", now)
114
  history.append(summary_copy)
115
+ self._state["history"][key] = history[-self._history_limit :]
116
+ return self.get_history(user_id, conversation_id)
117
+
118
+ if done:
119
+ if summary:
120
+ self._create_history_entry(user_id, conversation_id, summary)
121
+ self._delete_session(user_id, conversation_id)
122
+ else:
123
+ payload = self._session_payload(intent, metadata)
124
+ self._upsert_session(user_id, conversation_id, payload)
125
+ return self.get_history(user_id, conversation_id)
126
+
127
+ def set_metadata(
128
+ self,
129
+ user_id: str,
130
+ conversation_id: str,
131
+ metadata: Dict[str, Any],
132
+ ) -> None:
133
+ if not self._use_gateway:
134
+ key = _identifier(user_id, conversation_id)
135
  if metadata:
136
  meta_copy = copy.deepcopy(metadata)
137
  meta_copy["updated_at"] = time.time()
138
  self._state["metadata"][key] = meta_copy
139
  else:
140
  self._state["metadata"].pop(key, None)
141
+ return
142
+
143
+ if not metadata:
144
+ self._delete_session(user_id, conversation_id)
145
+ return
146
+
147
+ session = self._get_session(user_id, conversation_id)
148
+ intent = session.get("intent") if session else {}
149
+ payload = self._session_payload(intent or {}, metadata)
150
+ self._upsert_session(user_id, conversation_id, payload)
151
 
152
  def clear_metadata(self, user_id: str, conversation_id: str) -> None:
153
  self.set_metadata(user_id, conversation_id, {})
154
 
155
  def clear_intent(self, user_id: str, conversation_id: str) -> None:
156
+ if not self._use_gateway:
157
+ self._state["intents"].pop(_identifier(user_id, conversation_id), None)
158
+ self._state["metadata"].pop(_identifier(user_id, conversation_id), None)
159
+ return
160
+ self._delete_session(user_id, conversation_id)
 
 
161
 
162
  def get_metadata(self, user_id: str, conversation_id: str) -> Dict[str, Any]:
163
+ if not self._use_gateway:
164
+ record = self._state["metadata"].get(_identifier(user_id, conversation_id))
165
+ if not record:
 
 
166
  return {}
167
+ entry = copy.deepcopy(record)
168
  ts = entry.pop("updated_at", None)
169
  if ts is not None:
170
+ entry["updated_at"] = datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat()
171
  return entry
172
 
173
+ session = self._get_session(user_id, conversation_id)
174
+ if not session:
175
+ return {}
176
+
177
+ intent = session.get("intent") or {}
178
+ metadata: Dict[str, Any] = {
179
+ "event": session.get("event"),
180
+ "status": session.get("status"),
181
+ "missing_fields": session.get("missingFields") or [],
182
+ "next_field": session.get("nextField"),
183
+ "pending_question": session.get("pendingQuestion"),
184
+ "choices": session.get("choices") or [],
185
+ "error": session.get("errorMessage"),
186
+ "user_id": user_id,
187
+ "conversation_id": conversation_id,
188
+ }
189
+ metadata["from_network"] = intent.get("from_network")
190
+ metadata["from_token"] = intent.get("from_token")
191
+ metadata["to_network"] = intent.get("to_network")
192
+ metadata["to_token"] = intent.get("to_token")
193
+ metadata["amount"] = intent.get("amount")
194
+
195
+ history = self.get_history(user_id, conversation_id)
196
+ if history:
197
+ metadata["history"] = history
198
+
199
+ updated_at = session.get("updatedAt")
200
+ if updated_at:
201
+ metadata["updated_at"] = updated_at
202
+
203
+ return metadata
204
+
205
  def get_history(
206
  self,
207
  user_id: str,
208
  conversation_id: str,
209
  limit: Optional[int] = None,
210
  ) -> List[Dict[str, Any]]:
211
+ if not self._use_gateway:
212
+ key = _identifier(user_id, conversation_id)
213
+ history = self._state["history"].get(key, [])
214
+ effective = limit or self._history_limit
215
+ result: List[Dict[str, Any]] = []
216
+ for item in sorted(history, key=lambda entry: entry.get("timestamp", 0), reverse=True)[:effective]:
217
+ entry = copy.deepcopy(item)
218
+ ts = entry.get("timestamp")
219
+ if ts is not None:
220
+ entry["timestamp"] = datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat()
221
+ result.append(entry)
222
+ return result
223
+
224
+ effective_limit = limit or self._history_limit
225
+ result = self._client.list(
226
+ SWAP_HISTORY_ENTITY,
227
+ {
228
+ "where": {"userId": user_id, "conversationId": conversation_id},
229
+ "orderBy": {"recordedAt": "desc"},
230
+ "take": effective_limit,
231
+ },
232
+ )
233
+ data = result.get("data", []) if isinstance(result, dict) else []
234
+ history: List[Dict[str, Any]] = []
235
+ for entry in data:
236
+ history.append(
237
+ {
238
+ "status": entry.get("status"),
239
+ "from_network": entry.get("fromNetwork"),
240
+ "from_token": entry.get("fromToken"),
241
+ "to_network": entry.get("toNetwork"),
242
+ "to_token": entry.get("toToken"),
243
+ "amount": entry.get("amount"),
244
+ "error": entry.get("errorMessage"),
245
+ "timestamp": entry.get("recordedAt"),
246
+ }
247
+ )
248
+ return history
249
+
250
+ # ---- Gateway helpers --------------------------------------------------
251
+ def _get_session(self, user_id: str, conversation_id: str) -> Optional[Dict[str, Any]]:
252
+ identifier = _identifier(user_id, conversation_id)
253
+ try:
254
+ return self._client.get(SWAP_SESSION_ENTITY, identifier)
255
+ except PanoramaGatewayError as exc:
256
+ if exc.status_code == 404:
257
+ return None
258
+ raise
259
+
260
+ def _delete_session(self, user_id: str, conversation_id: str) -> None:
261
+ identifier = _identifier(user_id, conversation_id)
262
+ try:
263
+ self._client.delete(SWAP_SESSION_ENTITY, identifier)
264
+ except PanoramaGatewayError as exc:
265
+ if exc.status_code != 404:
266
+ raise
267
+
268
+ def _upsert_session(
269
+ self,
270
+ user_id: str,
271
+ conversation_id: str,
272
+ data: Dict[str, Any],
273
+ ) -> None:
274
+ identifier = _identifier(user_id, conversation_id)
275
+ payload = {**data, "updatedAt": _utc_now_iso()}
276
+ try:
277
+ self._client.update(SWAP_SESSION_ENTITY, identifier, payload)
278
+ except PanoramaGatewayError as exc:
279
+ if exc.status_code != 404:
280
+ raise
281
+ create_payload = {
282
+ "userId": user_id,
283
+ "conversationId": conversation_id,
284
+ "tenantId": self._settings.tenant_id,
285
+ **payload,
286
+ }
287
+ self._client.create(SWAP_SESSION_ENTITY, create_payload)
288
+
289
+ def _create_history_entry(
290
+ self,
291
+ user_id: str,
292
+ conversation_id: str,
293
+ summary: Dict[str, Any],
294
+ ) -> None:
295
+ history_payload = {
296
+ "userId": user_id,
297
+ "conversationId": conversation_id,
298
+ "status": summary.get("status"),
299
+ "fromNetwork": summary.get("from_network"),
300
+ "fromToken": summary.get("from_token"),
301
+ "toNetwork": summary.get("to_network"),
302
+ "toToken": summary.get("to_token"),
303
+ "amount": _as_float(summary.get("amount")),
304
+ "errorMessage": summary.get("error"),
305
+ "recordedAt": _utc_now_iso(),
306
+ "tenantId": self._settings.tenant_id,
307
+ }
308
+ self._client.create(SWAP_HISTORY_ENTITY, history_payload)
309
 
310
+ @staticmethod
311
+ def _session_payload(intent: Dict[str, Any], metadata: Dict[str, Any]) -> Dict[str, Any]:
312
+ missing = metadata.get("missing_fields") or []
313
+ if not isinstance(missing, list):
314
+ missing = list(missing)
315
+ return {
316
+ "status": metadata.get("status"),
317
+ "event": metadata.get("event"),
318
+ "intent": intent,
319
+ "missingFields": missing,
320
+ "nextField": metadata.get("next_field"),
321
+ "pendingQuestion": metadata.get("pending_question"),
322
+ "choices": metadata.get("choices"),
323
+ "errorMessage": metadata.get("error"),
324
+ "historyCursor": metadata.get("history_cursor") or 0,
325
+ }
src/app.py CHANGED
@@ -155,6 +155,20 @@ def chat(request: ChatRequest):
155
  try:
156
  user_id, conversation_id = _resolve_identity(request)
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  # Add the user message to the conversation
159
  chat_manager_instance.add_message(
160
  message=request.message.dict(),
 
155
  try:
156
  user_id, conversation_id = _resolve_identity(request)
157
 
158
+ wallet = request.wallet_address.strip() if request.wallet_address else None
159
+ if wallet and wallet.lower() == "default":
160
+ wallet = None
161
+ display_name = None
162
+ if isinstance(request.message.metadata, dict):
163
+ display_name = request.message.metadata.get("display_name")
164
+
165
+ chat_manager_instance.ensure_session(
166
+ user_id,
167
+ conversation_id,
168
+ wallet_address=wallet,
169
+ display_name=display_name,
170
+ )
171
+
172
  # Add the user message to the conversation
173
  chat_manager_instance.add_message(
174
  message=request.message.dict(),
src/integrations/panorama_gateway/client.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ import uuid
6
+ from dataclasses import asdict
7
+ from typing import Any, Dict, Iterable, Optional
8
+
9
+ import httpx
10
+ import jwt
11
+
12
+ from .config import PanoramaGatewaySettings, get_panorama_settings
13
+
14
+
15
+ class PanoramaGatewayError(RuntimeError):
16
+ """Raised when the Panorama gateway returns an error response."""
17
+
18
+ def __init__(self, message: str, status_code: int, payload: Any | None = None) -> None:
19
+ super().__init__(message)
20
+ self.status_code = status_code
21
+ self.payload = payload
22
+
23
+
24
+ def _encode_identifier(identifier: Any) -> str:
25
+ """Coerce identifiers into the colon-delimited format expected by the gateway."""
26
+
27
+ if isinstance(identifier, str):
28
+ return identifier
29
+ if isinstance(identifier, Iterable):
30
+ parts: Iterable[str] = (str(part) for part in identifier)
31
+ return ":".join(parts)
32
+ if isinstance(identifier, dict):
33
+ return ":".join(str(value) for value in identifier.values())
34
+ raise ValueError(f"Unsupported identifier type: {type(identifier)}")
35
+
36
+
37
+ class PanoramaGatewayClient:
38
+ """HTTP client wrapper for Panorama's data gateway."""
39
+
40
+ def __init__(
41
+ self,
42
+ settings: PanoramaGatewaySettings | None = None,
43
+ *,
44
+ client: httpx.Client | None = None,
45
+ ) -> None:
46
+ self._settings = settings or get_panorama_settings()
47
+ self._client = client or httpx.Client(
48
+ base_url=self._settings.base_url,
49
+ timeout=self._settings.request_timeout,
50
+ )
51
+
52
+ def __enter__(self) -> "PanoramaGatewayClient":
53
+ return self
54
+
55
+ def __exit__(self, exc_type, exc, tb) -> None:
56
+ self.close()
57
+
58
+ def close(self) -> None:
59
+ self._client.close()
60
+
61
+ # ---- low-level helpers -------------------------------------------------
62
+ def _build_token(self) -> str:
63
+ now = int(time.time())
64
+ payload: Dict[str, Any] = {
65
+ "iat": now,
66
+ "exp": now + 300,
67
+ "service": self._settings.service_name,
68
+ "roles": self._settings.roles,
69
+ "tenant": self._settings.tenant_id,
70
+ }
71
+ if self._settings.jwt_audience:
72
+ payload["aud"] = self._settings.jwt_audience
73
+ if self._settings.jwt_issuer:
74
+ payload["iss"] = self._settings.jwt_issuer
75
+
76
+ return jwt.encode(payload, self._settings.jwt_secret, algorithm="HS256")
77
+
78
+ def _default_headers(self) -> Dict[str, str]:
79
+ return {
80
+ "Authorization": f"Bearer {self._build_token()}",
81
+ "x-tenant-id": self._settings.tenant_id,
82
+ "Accept": "application/json",
83
+ }
84
+
85
+ def _request(
86
+ self,
87
+ method: str,
88
+ path: str,
89
+ *,
90
+ params: Dict[str, Any] | None = None,
91
+ json_body: Any | None = None,
92
+ idempotency_key: str | None = None,
93
+ ) -> Any:
94
+ headers = self._default_headers()
95
+ if method.upper() in {"POST", "PATCH", "PUT", "DELETE"}:
96
+ headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
97
+
98
+ response = self._client.request(
99
+ method=method,
100
+ url=path,
101
+ headers=headers,
102
+ params=params,
103
+ json=json_body,
104
+ )
105
+
106
+ if response.status_code >= 400:
107
+ message = f"Gateway request failed ({response.status_code})"
108
+ try:
109
+ payload = response.json()
110
+ except ValueError:
111
+ payload = response.text
112
+ raise PanoramaGatewayError(message, response.status_code, payload)
113
+
114
+ if response.status_code == 204:
115
+ return None
116
+
117
+ if response.headers.get("content-type", "").startswith("application/json"):
118
+ return response.json()
119
+ return response.text
120
+
121
+ # ---- CRUD facades ------------------------------------------------------
122
+ def list(self, entity: str, query: Dict[str, Any] | None = None) -> Dict[str, Any]:
123
+ params = None
124
+ if query:
125
+ params = {}
126
+ for key, value in query.items():
127
+ if isinstance(value, (dict, list)):
128
+ params[key] = json.dumps(value)
129
+ else:
130
+ params[key] = value
131
+ return self._request("GET", f"/v1/{entity}", params=params)
132
+
133
+ def get(self, entity: str, identifier: Any) -> Any:
134
+ encoded_id = _encode_identifier(identifier)
135
+ return self._request("GET", f"/v1/{entity}/{encoded_id}")
136
+
137
+ def create(
138
+ self,
139
+ entity: str,
140
+ payload: Dict[str, Any],
141
+ *,
142
+ idempotency_key: str | None = None,
143
+ ) -> Any:
144
+ return self._request(
145
+ "POST",
146
+ f"/v1/{entity}",
147
+ json_body=payload,
148
+ idempotency_key=idempotency_key,
149
+ )
150
+
151
+ def update(
152
+ self,
153
+ entity: str,
154
+ identifier: Any,
155
+ payload: Dict[str, Any],
156
+ *,
157
+ idempotency_key: str | None = None,
158
+ ) -> Any:
159
+ encoded_id = _encode_identifier(identifier)
160
+ return self._request(
161
+ "PATCH",
162
+ f"/v1/{entity}/{encoded_id}",
163
+ json_body=payload,
164
+ idempotency_key=idempotency_key,
165
+ )
166
+
167
+ def delete(
168
+ self,
169
+ entity: str,
170
+ identifier: Any,
171
+ *,
172
+ idempotency_key: str | None = None,
173
+ ) -> None:
174
+ encoded_id = _encode_identifier(identifier)
175
+ self._request(
176
+ "DELETE",
177
+ f"/v1/{entity}/{encoded_id}",
178
+ idempotency_key=idempotency_key,
179
+ )
180
+
181
+ def transact(
182
+ self,
183
+ operations: Iterable[Dict[str, Any]],
184
+ *,
185
+ idempotency_key: str | None = None,
186
+ ) -> Any:
187
+ payload = {"ops": list(operations)}
188
+ return self._request(
189
+ "POST",
190
+ "/v1/_transact",
191
+ json_body=payload,
192
+ idempotency_key=idempotency_key,
193
+ )
194
+
195
+ def to_dict(self) -> Dict[str, Any]:
196
+ """Return a serialisable snapshot of the current settings (useful for debugging)."""
197
+
198
+ return asdict(self._settings)
src/integrations/panorama_gateway/config.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from functools import lru_cache
6
+ from typing import List, Optional
7
+
8
+
9
+ def _split_roles(raw: str | None) -> List[str]:
10
+ if not raw:
11
+ return ["agent"]
12
+ return [part.strip() for part in raw.split(",") if part.strip()]
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class PanoramaGatewaySettings:
17
+ """Runtime configuration for the Panorama data gateway client."""
18
+
19
+ base_url: str
20
+ jwt_secret: str
21
+ tenant_id: str = "tenant-agent"
22
+ service_name: str = "zico-agent"
23
+ roles: List[str] = field(default_factory=lambda: ["agent"])
24
+ jwt_audience: Optional[str] = None
25
+ jwt_issuer: Optional[str] = None
26
+ request_timeout: float = 10.0
27
+
28
+ @classmethod
29
+ def load(cls) -> "PanoramaGatewaySettings":
30
+ base_url = os.getenv("PANORAMA_GATEWAY_URL")
31
+ if not base_url:
32
+ raise ValueError("PANORAMA_GATEWAY_URL environment variable is required.")
33
+
34
+ secret = os.getenv("PANORAMA_GATEWAY_JWT_SECRET") or os.getenv("JWT_SECRET")
35
+ if not secret:
36
+ raise ValueError(
37
+ "Set PANORAMA_GATEWAY_JWT_SECRET or reuse JWT_SECRET for Panorama gateway auth."
38
+ )
39
+
40
+ return cls(
41
+ base_url=base_url.rstrip("/"),
42
+ jwt_secret=secret,
43
+ tenant_id=os.getenv("PANORAMA_GATEWAY_TENANT", "tenant-agent"),
44
+ service_name=os.getenv("PANORAMA_GATEWAY_SERVICE", "zico-agent"),
45
+ roles=_split_roles(os.getenv("PANORAMA_GATEWAY_ROLES", "agent")),
46
+ jwt_audience=os.getenv("PANORAMA_GATEWAY_JWT_AUDIENCE"),
47
+ jwt_issuer=os.getenv("PANORAMA_GATEWAY_JWT_ISSUER"),
48
+ request_timeout=float(os.getenv("PANORAMA_GATEWAY_TIMEOUT", "10")),
49
+ )
50
+
51
+
52
+ @lru_cache(maxsize=1)
53
+ def get_panorama_settings() -> PanoramaGatewaySettings:
54
+ """Memoized accessor so callers share a single settings instance."""
55
+
56
+ return PanoramaGatewaySettings.load()
src/service/chat_manager.py CHANGED
@@ -1,125 +1,86 @@
 
 
1
  import logging
2
- import time
3
  from typing import Dict, List, Optional
4
- from src.models.chatMessage import ChatMessage, ConversationState, AgentResponse
5
- from datetime import datetime
 
6
 
7
  logger = logging.getLogger(__name__)
8
 
9
 
10
  class ChatManager:
11
- """
12
- Manages chat conversations and message history.
13
-
14
- This class provides functionality to:
15
- - Create and manage multiple conversations identified by unique IDs
16
- - Associate conversations with specific users
17
- - Add/retrieve messages and responses within conversations
18
- - Clear conversation history
19
- - Get chat history in different formats
20
- - Delete conversations
21
-
22
- Each conversation starts with a default disclaimer message about the experimental nature
23
- of the chatbot.
24
-
25
- Attributes:
26
- user_conversations (Dict[str, Dict[str, ConversationState]]): Dictionary mapping user IDs to their conversations
27
- default_message (ChatMessage): Default disclaimer message added to new conversations
28
-
29
- Example:
30
- >>> chat_manager = ChatManager()
31
- >>> chat_manager.add_message({"role": "user", "content": "Hello"}, "conv1", "user123")
32
- >>> messages = chat_manager.get_messages("conv1", "user123")
33
- """
34
-
35
- def __init__(self) -> None:
36
- self.user_conversations: Dict[str, Dict[str, ConversationState]] = {}
37
- self.default_message = ChatMessage(
38
- role="assistant",
39
- content="""This highly experimental chatbot is not intended for making important decisions. Its
40
- responses are generated using AI models and may not always be accurate.
41
- By using this chatbot, you acknowledge that you use it at your own discretion
42
- and assume all risks associated with its limitations and potential errors.""",
43
- metadata={},
44
- )
45
-
46
- # Backward compatibility - Initialize with default conversation for anonymous user
47
- self._initialize_user("anonymous")
48
- self.user_conversations["anonymous"]["default"] = ConversationState(
49
- conversation_id="default",
50
- user_id="anonymous",
51
- messages=[self.default_message],
52
- context={},
53
- memory={},
54
- agent_history=[],
55
- current_agent=None,
56
- last_message_id=None,
57
- created_at=datetime.utcnow(),
58
- updated_at=datetime.utcnow(),
59
- is_active=True
60
- )
61
-
62
- def _get_conversation_id(self, conversation_id: Optional[str] = None) -> str:
63
- """Helper method to get conversation ID, defaulting to 'default' if None provided"""
64
- return conversation_id or "default"
65
-
66
- def _get_user_id(self, user_id: Optional[str] = None) -> str:
67
- """Helper method to get user ID, defaulting to 'anonymous' if None provided"""
68
- return user_id or "anonymous"
69
-
70
- def _initialize_user(self, user_id: str) -> None:
71
- """Initialize conversations dictionary for a new user"""
72
- if user_id not in self.user_conversations:
73
- self.user_conversations[user_id] = {}
74
- logger.info(f"Initialized conversations for user {user_id}")
75
-
76
- def get_messages(self, conversation_id: Optional[str] = None, user_id: Optional[str] = None) -> List[Dict[str, str]]:
77
- """
78
- Get all messages for a specific conversation.
79
-
80
- Args:
81
- conversation_id (str, optional): Unique identifier for the conversation. Defaults to "default"
82
- user_id (str, optional): User identifier. Defaults to "anonymous"
83
-
84
- Returns:
85
- List[Dict[str, str]]: List of messages as dictionaries
86
- """
87
- user_id = self._get_user_id(user_id)
88
- conversation = self._get_or_create_conversation(self._get_conversation_id(conversation_id), user_id)
89
- return [msg.dict() for msg in conversation.messages]
90
-
91
- def add_message(self, message: Dict[str, str], conversation_id: Optional[str] = None, user_id: Optional[str] = None):
92
- """
93
- Add a new message to a conversation.
94
-
95
- Args:
96
- message (Dict[str, str]): Message to add
97
- conversation_id (str, optional): Conversation to add message to. Defaults to "default"
98
- user_id (str, optional): User identifier. Defaults to "anonymous"
99
- """
100
- user_id = self._get_user_id(user_id)
101
- conversation_id = self._get_conversation_id(conversation_id)
102
- conversation = self._get_or_create_conversation(conversation_id, user_id)
103
  chat_message = ChatMessage(**message)
 
 
104
  chat_message.conversation_id = conversation_id
105
  chat_message.user_id = user_id
106
- if "timestamp" not in message:
107
- chat_message.timestamp = datetime.utcnow()
108
- conversation.messages.append(chat_message)
109
- logger.info(f"Added message to conversation {conversation_id} for user {user_id}: {chat_message.content}")
110
-
111
- def add_response(self, response: Dict[str, str], agent_name: str, conversation_id: Optional[str] = None, user_id: Optional[str] = None):
112
- """
113
- Add an agent's response to a conversation.
114
-
115
- Args:
116
- response (Dict[str, str]): Response content
117
- agent_name (str): Name of the responding agent
118
- conversation_id (str, optional): Conversation to add response to. Defaults to "default"
119
- user_id (str, optional): User identifier. Defaults to "anonymous"
120
- """
 
121
  agent_response = AgentResponse(**response)
122
- # You may need to implement to_chat_message if not present
123
  chat_message = ChatMessage(
124
  role="assistant",
125
  content=agent_response.content,
@@ -131,152 +92,82 @@ class ChatManager:
131
  next_agent=agent_response.next_agent,
132
  requires_followup=agent_response.requires_followup,
133
  status="completed" if agent_response.success else "failed",
134
- error_message=agent_response.error_message
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  )
136
- self.add_message(chat_message.dict(), self._get_conversation_id(conversation_id), self._get_user_id(user_id))
137
- logger.info(f"Added response from agent {agent_name} to conversation {conversation_id} for user {user_id}")
138
-
139
- def clear_messages(self, conversation_id: Optional[str] = None, user_id: Optional[str] = None):
140
- """
141
- Clear all messages in a conversation except the default message.
142
-
143
- Args:
144
- conversation_id (str, optional): Conversation to clear. Defaults to "default"
145
- user_id (str, optional): User identifier. Defaults to "anonymous"
146
- """
147
- user_id = self._get_user_id(user_id)
148
- conversation = self._get_or_create_conversation(self._get_conversation_id(conversation_id), user_id)
149
- conversation.messages = [self.default_message] # Keep the initial message
150
- logger.info(f"Cleared message history for conversation {conversation_id} for user {user_id}")
151
-
152
- def get_last_message(self, conversation_id: Optional[str] = None, user_id: Optional[str] = None) -> Dict[str, str]:
153
- """
154
- Get the most recent message from a conversation.
155
-
156
- Args:
157
- conversation_id (str, optional): Conversation to get message from. Defaults to "default"
158
- user_id (str, optional): User identifier. Defaults to "anonymous"
159
-
160
- Returns:
161
- Dict[str, str]: Last message or empty dict if no messages
162
- """
163
- user_id = self._get_user_id(user_id)
164
- conversation = self._get_or_create_conversation(self._get_conversation_id(conversation_id), user_id)
165
- return conversation.messages[-1].dict() if conversation.messages else {}
166
-
167
- def get_chat_history(self, conversation_id: Optional[str] = None, user_id: Optional[str] = None) -> str:
168
- """
169
- Get formatted chat history for a conversation.
170
-
171
- Args:
172
- conversation_id (str, optional): Conversation to get history for. Defaults to "default"
173
- user_id (str, optional): User identifier. Defaults to "anonymous"
174
-
175
- Returns:
176
- str: Formatted chat history as string
177
- """
178
- user_id = self._get_user_id(user_id)
179
- conversation = self._get_or_create_conversation(self._get_conversation_id(conversation_id), user_id)
180
- return "\n".join([f"{msg.role}: {msg.content}" for msg in conversation.messages])
181
-
182
- def get_all_conversation_ids(self, user_id: Optional[str] = None) -> List[str]:
183
- """
184
- Get a list of all conversation IDs for a specific user.
185
-
186
- Args:
187
- user_id (str, optional): User identifier. Defaults to "anonymous"
188
-
189
- Returns:
190
- List[str]: List of conversation IDs for the user
191
- """
192
- user_id = self._get_user_id(user_id)
193
- self._initialize_user(user_id)
194
- return list(self.user_conversations[user_id].keys())
195
-
196
- def get_all_user_ids(self) -> List[str]:
197
- """
198
- Get a list of all user IDs.
199
-
200
- Returns:
201
- List[str]: List of user IDs
202
- """
203
- return list(self.user_conversations.keys())
204
-
205
- def delete_conversation(self, conversation_id: Optional[str] = None, user_id: Optional[str] = None):
206
- """
207
- Delete a conversation by ID.
208
 
209
- Args:
210
- conversation_id (str, optional): ID of conversation to delete. Defaults to "default"
211
- user_id (str, optional): User identifier. Defaults to "anonymous"
212
- """
213
- user_id = self._get_user_id(user_id)
214
- conversation_id = self._get_conversation_id(conversation_id)
215
- self._initialize_user(user_id)
216
- if conversation_id in self.user_conversations[user_id]:
217
- del self.user_conversations[user_id][conversation_id]
218
- logger.info(f"Deleted conversation {conversation_id} for user {user_id}")
 
 
219
 
220
  def create_conversation(self, user_id: Optional[str] = None) -> str:
221
- """
222
- Create a new conversation for a user.
223
-
224
- Args:
225
- user_id (str, optional): User identifier. Defaults to "anonymous"
226
-
227
- Returns:
228
- str: ID of the created conversation
229
- """
230
- user_id = self._get_user_id(user_id)
231
- self._initialize_user(user_id)
232
- conversation_id = f"conversation_{len(self.user_conversations[user_id])}"
233
- while conversation_id in self.user_conversations[user_id]:
234
- conversation_id = f"conversation_{len(self.user_conversations[user_id])}_{int(time.time())}"
235
- self.user_conversations[user_id][conversation_id] = ConversationState(
236
- conversation_id=conversation_id,
237
- user_id=user_id,
238
- messages=[self.default_message],
239
- context={},
240
- memory={},
241
- agent_history=[],
242
- current_agent=None,
243
- last_message_id=None,
244
- created_at=datetime.utcnow(),
245
- updated_at=datetime.utcnow(),
246
- is_active=True
247
  )
248
- logger.info(f"Created new conversation {conversation_id} for user {user_id}")
249
  return conversation_id
250
 
251
- def _get_or_create_conversation(self, conversation_id: str, user_id: str) -> ConversationState:
252
- """
253
- Get existing conversation or create new one if not exists.
254
-
255
- Args:
256
- conversation_id (str): Conversation ID to get/create
257
- user_id (str): User identifier
258
 
259
- Returns:
260
- ConversationState: Retrieved or created conversation
261
- """
262
- self._initialize_user(user_id)
263
- if conversation_id not in self.user_conversations[user_id]:
264
- self.user_conversations[user_id][conversation_id] = ConversationState(
265
- conversation_id=conversation_id,
266
- user_id=user_id,
267
- messages=[self.default_message],
268
- context={},
269
- memory={},
270
- agent_history=[],
271
- current_agent=None,
272
- last_message_id=None,
273
- created_at=datetime.utcnow(),
274
- updated_at=datetime.utcnow(),
275
- is_active=True
276
- )
277
- logger.info(f"Created new conversation {conversation_id} for user {user_id}")
278
- return self.user_conversations[user_id][conversation_id]
279
 
280
 
281
- # Create an instance to act as a singleton store
282
  chat_manager_instance = ChatManager()
 
1
+ from __future__ import annotations
2
+
3
  import logging
4
+ import uuid
5
  from typing import Dict, List, Optional
6
+
7
+ from src.models.chatMessage import AgentResponse, ChatMessage
8
+ from src.service.panorama_store import PanoramaStore
9
 
10
  logger = logging.getLogger(__name__)
11
 
12
 
13
  class ChatManager:
14
+ """Facade that delegates chat persistence to the Panorama data gateway."""
15
+
16
+ def __init__(self, store: PanoramaStore | None = None) -> None:
17
+ self._store = store or PanoramaStore()
18
+
19
+ @staticmethod
20
+ def _resolve_ids(
21
+ conversation_id: Optional[str],
22
+ user_id: Optional[str],
23
+ ) -> tuple[str, str]:
24
+ return (conversation_id or "default", user_id or "anonymous")
25
+
26
+ # ---- Message access ---------------------------------------------------
27
+ def get_messages(
28
+ self,
29
+ conversation_id: Optional[str] = None,
30
+ user_id: Optional[str] = None,
31
+ ) -> List[Dict[str, str]]:
32
+ conversation_id, user_id = self._resolve_ids(conversation_id, user_id)
33
+ self._store.ensure_conversation(user_id, conversation_id)
34
+ messages = self._store.list_messages(user_id, conversation_id)
35
+ return messages
36
+
37
+ def get_last_message(
38
+ self,
39
+ conversation_id: Optional[str] = None,
40
+ user_id: Optional[str] = None,
41
+ ) -> Dict[str, str]:
42
+ messages = self.get_messages(conversation_id, user_id)
43
+ return messages[-1] if messages else {}
44
+
45
+ def get_chat_history(
46
+ self,
47
+ conversation_id: Optional[str] = None,
48
+ user_id: Optional[str] = None,
49
+ ) -> str:
50
+ messages = self.get_messages(conversation_id, user_id)
51
+ return "\n".join(f"{msg.get('role')}: {msg.get('content')}" for msg in messages)
52
+
53
+ # ---- Mutations --------------------------------------------------------
54
+ def add_message(
55
+ self,
56
+ message: Dict[str, str],
57
+ conversation_id: Optional[str] = None,
58
+ user_id: Optional[str] = None,
59
+ ) -> Dict[str, str]:
60
+ conversation_id, user_id = self._resolve_ids(conversation_id, user_id)
61
+ self._store.ensure_user_and_conversation(user_id, conversation_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  chat_message = ChatMessage(**message)
63
+ if not chat_message.message_id:
64
+ chat_message.message_id = str(uuid.uuid4())
65
  chat_message.conversation_id = conversation_id
66
  chat_message.user_id = user_id
67
+ stored = self._store.add_message(user_id, conversation_id, chat_message)
68
+ logger.info(
69
+ "Persisted message for user=%s conversation=%s role=%s",
70
+ user_id,
71
+ conversation_id,
72
+ chat_message.role,
73
+ )
74
+ return stored
75
+
76
+ def add_response(
77
+ self,
78
+ response: Dict[str, str],
79
+ agent_name: str,
80
+ conversation_id: Optional[str] = None,
81
+ user_id: Optional[str] = None,
82
+ ) -> Dict[str, str]:
83
  agent_response = AgentResponse(**response)
 
84
  chat_message = ChatMessage(
85
  role="assistant",
86
  content=agent_response.content,
 
92
  next_agent=agent_response.next_agent,
93
  requires_followup=agent_response.requires_followup,
94
  status="completed" if agent_response.success else "failed",
95
+ error_message=agent_response.error_message,
96
+ )
97
+ stored = self.add_message(
98
+ chat_message.dict(),
99
+ conversation_id=conversation_id,
100
+ user_id=user_id,
101
+ )
102
+ logger.info(
103
+ "Persisted agent response from %s for user=%s conversation=%s",
104
+ agent_name,
105
+ user_id,
106
+ conversation_id,
107
+ )
108
+ return stored
109
+
110
+ def clear_messages(
111
+ self,
112
+ conversation_id: Optional[str] = None,
113
+ user_id: Optional[str] = None,
114
+ ) -> None:
115
+ conversation_id, user_id = self._resolve_ids(conversation_id, user_id)
116
+ self._store.ensure_conversation(user_id, conversation_id)
117
+ self._store.reset_conversation(user_id, conversation_id)
118
+ logger.info(
119
+ "Cleared messages for user=%s conversation=%s",
120
+ user_id,
121
+ conversation_id,
122
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
+ def delete_conversation(
125
+ self,
126
+ conversation_id: Optional[str] = None,
127
+ user_id: Optional[str] = None,
128
+ ) -> None:
129
+ conversation_id, user_id = self._resolve_ids(conversation_id, user_id)
130
+ self._store.delete_conversation(user_id, conversation_id)
131
+ logger.info(
132
+ "Deleted conversation for user=%s conversation=%s",
133
+ user_id,
134
+ conversation_id,
135
+ )
136
 
137
  def create_conversation(self, user_id: Optional[str] = None) -> str:
138
+ user_id = (user_id or "anonymous")
139
+ conversation_id = f"conversation-{uuid.uuid4().hex[:8]}"
140
+ self._store.ensure_user_and_conversation(user_id, conversation_id)
141
+ logger.info(
142
+ "Created new conversation for user=%s conversation=%s",
143
+ user_id,
144
+ conversation_id,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  )
 
146
  return conversation_id
147
 
148
+ # ---- Discovery helpers ------------------------------------------------
149
+ def get_all_conversation_ids(self, user_id: Optional[str] = None) -> List[str]:
150
+ user_id = (user_id or "anonymous")
151
+ return self._store.list_conversations(user_id)
 
 
 
152
 
153
+ def get_all_user_ids(self) -> List[str]:
154
+ return self._store.list_users()
155
+
156
+ def ensure_session(
157
+ self,
158
+ user_id: str,
159
+ conversation_id: str,
160
+ *,
161
+ wallet_address: Optional[str] = None,
162
+ display_name: Optional[str] = None,
163
+ ) -> None:
164
+ self._store.ensure_user_and_conversation(
165
+ user_id,
166
+ conversation_id,
167
+ wallet_address=wallet_address,
168
+ display_name=display_name,
169
+ )
 
 
 
170
 
171
 
172
+ # Singleton-style accessor for the FastAPI routes
173
  chat_manager_instance = ChatManager()
src/service/panorama_store.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ from src.models.chatMessage import ChatMessage
9
+
10
+ from src.integrations.panorama_gateway import (
11
+ PanoramaGatewayClient,
12
+ PanoramaGatewayError,
13
+ PanoramaGatewaySettings,
14
+ get_panorama_settings,
15
+ )
16
+
17
+
18
+ DEFAULT_DISCLAIMER = (
19
+ "This highly experimental chatbot is not intended for making important decisions. "
20
+ "Its responses are generated using AI models and may not always be accurate. "
21
+ "By using this chatbot, you acknowledge that you use it at your own discretion "
22
+ "and assume all risks associated with its limitations and potential errors."
23
+ )
24
+
25
+
26
+ def _utc_now_iso() -> str:
27
+ """Return an RFC3339 timestamp with millisecond precision and Z suffix."""
28
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
29
+
30
+
31
+ def _normalize_datetime(value: Any | None) -> Optional[str]:
32
+ """Convert datetime-like inputs to gateway-friendly RFC3339 strings."""
33
+ if value is None:
34
+ return None
35
+ if isinstance(value, datetime):
36
+ dt = value
37
+ elif isinstance(value, str):
38
+ if not value:
39
+ return None
40
+ try:
41
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
42
+ except ValueError:
43
+ return value
44
+ else:
45
+ return value
46
+
47
+ if dt.tzinfo is None:
48
+ dt = dt.replace(tzinfo=timezone.utc)
49
+ else:
50
+ dt = dt.astimezone(timezone.utc)
51
+ return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")
52
+
53
+
54
+ def _conversation_key(user_id: str, conversation_id: str) -> str:
55
+ return f"{user_id}:{conversation_id}"
56
+
57
+
58
+ def _drop_none(data: Dict[str, Any]) -> Dict[str, Any]:
59
+ """Remove keys with None values so the gateway never receives JSON null."""
60
+ return {key: value for key, value in data.items() if value is not None}
61
+
62
+
63
+ class PanoramaStore:
64
+ """Bridges chat persistence to the Panorama data gateway."""
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ client: PanoramaGatewayClient | None = None,
70
+ settings: PanoramaGatewaySettings | None = None,
71
+ ) -> None:
72
+ self._settings = settings or get_panorama_settings()
73
+ self._client = client or PanoramaGatewayClient(self._settings)
74
+ self._logger = logging.getLogger(__name__)
75
+
76
+ # ---- user helpers -----------------------------------------------------
77
+ def ensure_user(
78
+ self,
79
+ user_id: str,
80
+ *,
81
+ wallet_address: Optional[str] = None,
82
+ display_name: Optional[str] = None,
83
+ attributes: Optional[Dict[str, Any]] = None,
84
+ ) -> Dict[str, Any]:
85
+ try:
86
+ user = self._client.get("users", user_id)
87
+ except PanoramaGatewayError as exc:
88
+ if exc.status_code != 404:
89
+ raise
90
+ user = None
91
+
92
+ if user:
93
+ # Update last seen without failing the request if the patch raises.
94
+ try:
95
+ payload: Dict[str, Any] = {"lastSeenAt": _utc_now_iso()}
96
+ if display_name:
97
+ payload["displayName"] = display_name
98
+ if wallet_address:
99
+ payload["walletAddress"] = wallet_address
100
+ if attributes:
101
+ payload["attributes"] = attributes
102
+ self._client.update("users", user_id, payload)
103
+ except PanoramaGatewayError:
104
+ pass
105
+ return user
106
+
107
+ payload = _drop_none(
108
+ {
109
+ "userId": user_id,
110
+ "walletAddress": wallet_address,
111
+ "displayName": display_name,
112
+ "attributes": attributes or {},
113
+ "tenantId": self._settings.tenant_id,
114
+ "createdAt": _utc_now_iso(),
115
+ "lastSeenAt": _utc_now_iso(),
116
+ }
117
+ )
118
+ try:
119
+ return self._client.create("users", payload)
120
+ except PanoramaGatewayError as exc:
121
+ self._logger.error(
122
+ "Failed to create user %s via gateway: status=%s payload=%s request=%s",
123
+ user_id,
124
+ exc.status_code,
125
+ exc.payload,
126
+ payload,
127
+ )
128
+ raise
129
+
130
+ # ---- conversation helpers ---------------------------------------------
131
+ def ensure_conversation(
132
+ self,
133
+ user_id: str,
134
+ conversation_id: str,
135
+ *,
136
+ title: Optional[str] = None,
137
+ ) -> Dict[str, Any]:
138
+ conv_key = _conversation_key(user_id, conversation_id)
139
+ try:
140
+ conversation = self._client.get("conversations", conv_key)
141
+ except PanoramaGatewayError as exc:
142
+ if exc.status_code != 404:
143
+ raise
144
+ conversation = None
145
+
146
+ if conversation:
147
+ return conversation
148
+
149
+ payload = _drop_none(
150
+ {
151
+ "id": conv_key,
152
+ "userId": user_id,
153
+ "conversationId": conversation_id,
154
+ "title": title,
155
+ "status": "active",
156
+ "messageCount": 0,
157
+ "tenantId": self._settings.tenant_id,
158
+ "contextState": {},
159
+ "memoryState": {},
160
+ "createdAt": _utc_now_iso(),
161
+ "updatedAt": _utc_now_iso(),
162
+ }
163
+ )
164
+ try:
165
+ conversation = self._client.create("conversations", payload)
166
+ except PanoramaGatewayError as exc:
167
+ self._logger.error(
168
+ "Failed to create conversation %s for user %s: status=%s payload=%s request=%s",
169
+ conversation_id,
170
+ user_id,
171
+ exc.status_code,
172
+ exc.payload,
173
+ payload,
174
+ )
175
+ raise
176
+ self._create_disclaimer_message(user_id, conversation_id)
177
+ return conversation
178
+
179
+ def list_conversations(self, user_id: str) -> List[str]:
180
+ result = self._client.list(
181
+ "conversations",
182
+ {
183
+ "where": {"userId": user_id},
184
+ "orderBy": {"updatedAt": "desc"},
185
+ },
186
+ )
187
+ data = result.get("data", []) if isinstance(result, dict) else []
188
+ return [item.get("conversationId") for item in data if item.get("conversationId")]
189
+
190
+ def list_users(self, limit: int = 1000) -> List[str]:
191
+ result = self._client.list(
192
+ "users",
193
+ {
194
+ "orderBy": {"createdAt": "desc"},
195
+ "take": limit,
196
+ },
197
+ )
198
+ data = result.get("data", []) if isinstance(result, dict) else []
199
+ return [item.get("userId") for item in data if item.get("userId")]
200
+
201
+ def delete_conversation(self, user_id: str, conversation_id: str) -> None:
202
+ messages = self.list_messages(user_id, conversation_id)
203
+ deletes: List[Dict[str, Any]] = [
204
+ {
205
+ "op": "delete",
206
+ "entity": "messages",
207
+ "args": {"id": message["messageId"]},
208
+ }
209
+ for message in messages
210
+ if message.get("messageId")
211
+ ]
212
+ if deletes:
213
+ self._client.transact(deletes)
214
+
215
+ conv_key = _conversation_key(user_id, conversation_id)
216
+ try:
217
+ self._client.delete("conversations", conv_key)
218
+ except PanoramaGatewayError as exc:
219
+ if exc.status_code != 404:
220
+ raise
221
+
222
+ def reset_conversation(self, user_id: str, conversation_id: str) -> None:
223
+ messages = self.list_messages(user_id, conversation_id)
224
+ deletes = [
225
+ {
226
+ "op": "delete",
227
+ "entity": "messages",
228
+ "args": {"id": message["messageId"]},
229
+ }
230
+ for message in messages
231
+ if message.get("messageId")
232
+ ]
233
+ if deletes:
234
+ self._client.transact(deletes)
235
+
236
+ conv_key = _conversation_key(user_id, conversation_id)
237
+ self._client.update(
238
+ "conversations",
239
+ conv_key,
240
+ {"messageCount": 0, "updatedAt": _utc_now_iso()},
241
+ )
242
+ self._create_disclaimer_message(user_id, conversation_id)
243
+
244
+ # ---- message helpers ---------------------------------------------------
245
+ def list_messages(self, user_id: str, conversation_id: str) -> List[Dict[str, Any]]:
246
+ result = self._client.list(
247
+ "messages",
248
+ {
249
+ "where": {"userId": user_id, "conversationId": conversation_id},
250
+ "orderBy": {"timestamp": "asc"},
251
+ },
252
+ )
253
+ if isinstance(result, dict):
254
+ return result.get("data", [])
255
+ return []
256
+
257
+ def add_message(
258
+ self,
259
+ user_id: str,
260
+ conversation_id: str,
261
+ message: ChatMessage,
262
+ ) -> Dict[str, Any]:
263
+ conversation = self.ensure_conversation(user_id, conversation_id)
264
+ message_dict = message.dict()
265
+ message_id = message_dict.get("message_id") or message_dict.get("messageId")
266
+ if not message_id:
267
+ message_id = str(uuid.uuid4())
268
+ timestamp = _normalize_datetime(message_dict.get("timestamp")) or _utc_now_iso()
269
+ message_payload = _drop_none(
270
+ {
271
+ "messageId": message_id,
272
+ "userId": user_id,
273
+ "conversationId": conversation_id,
274
+ "role": message_dict.get("role"),
275
+ "content": message_dict.get("content"),
276
+ "agentName": message_dict.get("agent_name"),
277
+ "agentType": message_dict.get("agent_type"),
278
+ "requiresAction": message_dict.get("requires_action", False),
279
+ "actionType": message_dict.get("action_type"),
280
+ "metadata": message_dict.get("metadata") or {},
281
+ "status": message_dict.get("status"),
282
+ "errorMessage": message_dict.get("error_message"),
283
+ "toolCalls": message_dict.get("tool_calls"),
284
+ "toolResults": message_dict.get("tool_results"),
285
+ "nextAgent": message_dict.get("next_agent"),
286
+ "requiresFollowup": message_dict.get("requires_followup", False),
287
+ "timestamp": timestamp,
288
+ "tenantId": self._settings.tenant_id,
289
+ }
290
+ )
291
+
292
+ operations = [
293
+ {"op": "create", "entity": "messages", "args": {"data": message_payload}},
294
+ {
295
+ "op": "update",
296
+ "entity": "conversations",
297
+ "args": {
298
+ "id": conversation["id"],
299
+ "data": {
300
+ "lastMessageId": message_payload["messageId"],
301
+ "messageCount": (conversation.get("messageCount") or 0) + 1,
302
+ "updatedAt": _utc_now_iso(),
303
+ },
304
+ },
305
+ },
306
+ ]
307
+ self._client.transact(operations)
308
+
309
+ memory_payload = _drop_none(
310
+ {
311
+ "messageId": message_payload["messageId"],
312
+ "role": message_payload["role"],
313
+ "content": message_payload["content"],
314
+ "agentName": message_payload.get("agentName"),
315
+ "agentType": message_payload.get("agentType"),
316
+ "metadata": message_payload.get("metadata"),
317
+ "requiresAction": message_payload.get("requiresAction"),
318
+ "timestamp": message_payload.get("timestamp"),
319
+ }
320
+ )
321
+ try:
322
+ self.create_conversation_memory(
323
+ user_id,
324
+ conversation_id,
325
+ scope="conversation",
326
+ memory_type=message_payload["role"],
327
+ payload=memory_payload,
328
+ )
329
+ except PanoramaGatewayError:
330
+ # Memory writes should never block the main chat flow.
331
+ pass
332
+
333
+ return message_payload
334
+
335
+ def _create_disclaimer_message(self, user_id: str, conversation_id: str) -> None:
336
+ disclaimer = ChatMessage(
337
+ role="assistant",
338
+ content=DEFAULT_DISCLAIMER,
339
+ metadata={},
340
+ user_id=user_id,
341
+ conversation_id=conversation_id,
342
+ )
343
+ self.add_message(user_id, conversation_id, disclaimer)
344
+
345
+ # ---- conversation memory ----------------------------------------------
346
+ def create_conversation_memory(
347
+ self,
348
+ user_id: str,
349
+ conversation_id: Optional[str],
350
+ scope: str,
351
+ memory_type: str,
352
+ payload: Dict[str, Any],
353
+ *,
354
+ label: Optional[str] = None,
355
+ importance_score: Optional[float] = None,
356
+ expires_at: Optional[Any] = None,
357
+ ) -> Dict[str, Any]:
358
+ memory_payload = _drop_none(
359
+ {
360
+ "userId": user_id,
361
+ "conversationId": conversation_id,
362
+ "scope": scope,
363
+ "memoryType": memory_type,
364
+ "payload": payload,
365
+ "tenantId": self._settings.tenant_id,
366
+ "label": label,
367
+ "importanceScore": importance_score,
368
+ "expiresAt": _normalize_datetime(expires_at),
369
+ "createdAt": _utc_now_iso(),
370
+ "updatedAt": _utc_now_iso(),
371
+ }
372
+ )
373
+ return self._client.create("conversation-memories", memory_payload)
374
+
375
+ # ---- utility -----------------------------------------------------------
376
+ def ensure_user_and_conversation(
377
+ self,
378
+ user_id: str,
379
+ conversation_id: str,
380
+ *,
381
+ wallet_address: Optional[str] = None,
382
+ display_name: Optional[str] = None,
383
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
384
+ user = self.ensure_user(
385
+ user_id,
386
+ wallet_address=wallet_address,
387
+ display_name=display_name,
388
+ )
389
+ conversation = self.ensure_conversation(user_id, conversation_id)
390
+ return user, conversation