zaheer140 commited on
Commit
a6e3f67
·
verified ·
1 Parent(s): 3b9e957

Upload 465 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. SuperAgent/__pycache__/main.cpython-312.pyc +0 -0
  2. SuperAgent/__pycache__/main.cpython-313.pyc +0 -0
  3. SuperAgent/app/__init__.py +1 -0
  4. SuperAgent/app/__pycache__/__init__.cpython-312.pyc +0 -0
  5. SuperAgent/app/__pycache__/__init__.cpython-313.pyc +0 -0
  6. SuperAgent/app/models/__pycache__/schemas.cpython-312.pyc +0 -0
  7. SuperAgent/app/models/__pycache__/schemas.cpython-313.pyc +0 -0
  8. SuperAgent/app/models/schemas.py +24 -0
  9. SuperAgent/app/services/__pycache__/asset_registry.cpython-312.pyc +0 -0
  10. SuperAgent/app/services/__pycache__/asset_registry.cpython-313.pyc +0 -0
  11. SuperAgent/app/services/__pycache__/email_service.cpython-313.pyc +0 -0
  12. SuperAgent/app/services/__pycache__/report_generator.cpython-313.pyc +0 -0
  13. SuperAgent/app/services/__pycache__/router.cpython-312.pyc +0 -0
  14. SuperAgent/app/services/__pycache__/router.cpython-313.pyc +0 -0
  15. SuperAgent/app/services/asset_registry.py +390 -0
  16. SuperAgent/app/services/email_service.py +76 -0
  17. SuperAgent/app/services/report_generator.py +13 -0
  18. SuperAgent/app/services/router.py +881 -0
  19. SuperAgent/main.py +970 -0
  20. SuperAgent/scratch/debug_sql.txt +18 -0
  21. SuperAgent/scratch/raw_llm_output.txt +31 -0
  22. SuperAgent/scratch/read_pdf.py +44 -0
  23. SuperAgent/scratch/reports/report_1st_f_20260604_170502.pdf +0 -0
  24. SuperAgent/scratch/reports/report_cold_room_01_20260603_101356.pdf +0 -0
  25. SuperAgent/scratch/reports/report_cold_room_01_20260605_164130.pdf +0 -0
  26. SuperAgent/scratch/reports/report_cold_room_02_20260601_111023.pdf +0 -0
  27. SuperAgent/scratch/reports/report_cold_room_02_20260601_112724.pdf +0 -0
  28. SuperAgent/scratch/reports/report_cold_room_03_20260601_110725.pdf +0 -0
  29. SuperAgent/scratch/reports/report_coldroom10_20260520_151010.pdf +0 -0
  30. SuperAgent/scratch/reports/report_coldroom10_20260520_151223.pdf +0 -0
  31. SuperAgent/scratch/reports/report_coldroom10_20260520_154908.pdf +0 -0
  32. SuperAgent/scratch/reports/report_coldroom10_20260520_155631.pdf +0 -0
  33. SuperAgent/scratch/reports/report_coldroom1_20260520_161047.pdf +0 -0
  34. SuperAgent/scratch/reports/report_coldroom1_20260520_161326.pdf +0 -0
  35. SuperAgent/scratch/reports/report_coldroom1_20260520_162115.pdf +0 -0
  36. SuperAgent/scratch/reports/report_coldroom1_20260520_164714.pdf +0 -0
  37. SuperAgent/scratch/reports/report_coldroom1_20260520_171604.pdf +0 -0
  38. SuperAgent/scratch/reports/report_coldroom1_20260520_172140.pdf +0 -0
  39. SuperAgent/scratch/reports/report_coldroom1_20260520_172737.pdf +0 -0
  40. SuperAgent/scratch/reports/report_coldroom1_20260521_114641.pdf +0 -0
  41. SuperAgent/scratch/reports/report_coldroom1_20260601_104530.pdf +0 -0
  42. SuperAgent/scratch/reports/report_coldroom1_20260601_105254.pdf +0 -0
  43. SuperAgent/scratch/reports/report_coldroom1_20260609_151700.xlsx +0 -0
  44. SuperAgent/scratch/reports/report_coldroom1_20260618_150027.pdf +0 -0
  45. SuperAgent/scratch/reports/report_coldroom1_20260622_113931.pdf +0 -0
  46. SuperAgent/scratch/reports/report_coldroom1_20260622_153634.pdf +0 -0
  47. SuperAgent/scratch/reports/report_physical05_20260604_171330.pdf +0 -0
  48. SuperAgent/scratch/reports/report_physical06_20260604_170048.pdf +0 -0
  49. SuperAgent/scratch/reports/report_physical_03_20260603_101528.xlsx +0 -0
  50. SuperAgent/scratch/reports/report_physical_refinery_20260604_171051.pdf +0 -0
SuperAgent/__pycache__/main.cpython-312.pyc ADDED
Binary file (8.65 kB). View file
 
SuperAgent/__pycache__/main.cpython-313.pyc ADDED
Binary file (50.8 kB). View file
 
SuperAgent/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Package marker
SuperAgent/app/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (161 Bytes). View file
 
SuperAgent/app/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (166 Bytes). View file
 
SuperAgent/app/models/__pycache__/schemas.cpython-312.pyc ADDED
Binary file (1.17 kB). View file
 
SuperAgent/app/models/__pycache__/schemas.cpython-313.pyc ADDED
Binary file (1.76 kB). View file
 
SuperAgent/app/models/schemas.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Literal, Optional
2
+ from pydantic import BaseModel, Field
3
+
4
+ # Reuse the basic structure for compatibility
5
+ class ChatRequest(BaseModel):
6
+ message: str
7
+ asset_id: Optional[str] = None
8
+ session_id: Optional[str] = "default"
9
+ user_id: Optional[str] = None # Resolved from "User_ID: question" prefix or DEFAULT_USER_ID env var
10
+
11
+
12
+ class ChatResponse(BaseModel):
13
+ answer: str
14
+ intent: Literal["live", "analysis", "anomaly", "report"]
15
+ analysis: Optional[Any] = None # Will hold AnalysisResponse if intent is analysis
16
+ data: Optional[Any] = None # Will hold rows if intent is live, or complex reports if anomaly
17
+ generated_sql: Optional[str] = None
18
+ orchestration: Optional[list[str]] = None
19
+
20
+ class SessionCreateRequest(BaseModel):
21
+ user_id: Optional[str] = None
22
+
23
+ class SessionTitleUpdateRequest(BaseModel):
24
+ title: str
SuperAgent/app/services/__pycache__/asset_registry.cpython-312.pyc ADDED
Binary file (9.23 kB). View file
 
SuperAgent/app/services/__pycache__/asset_registry.cpython-313.pyc ADDED
Binary file (19.7 kB). View file
 
SuperAgent/app/services/__pycache__/email_service.cpython-313.pyc ADDED
Binary file (4.68 kB). View file
 
SuperAgent/app/services/__pycache__/report_generator.cpython-313.pyc ADDED
Binary file (777 Bytes). View file
 
SuperAgent/app/services/__pycache__/router.cpython-312.pyc ADDED
Binary file (21.1 kB). View file
 
SuperAgent/app/services/__pycache__/router.cpython-313.pyc ADDED
Binary file (42.6 kB). View file
 
SuperAgent/app/services/asset_registry.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import time
4
+ import logging
5
+ from typing import Dict, List, Optional, Any
6
+ from sqlalchemy import create_engine, text
7
+ from sqlalchemy.orm import sessionmaker
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class AssetRegistry:
12
+ """
13
+ Centralized Asset Registry for the SuperAgent.
14
+ Standardizes asset naming and mapping across all agents.
15
+ """
16
+ def __init__(self):
17
+ db_url = os.environ.get('DATABASE_URL', 'postgresql://postgres:postgres@109.176.197.144:55432/openremote')
18
+ if db_url.startswith("postgresql://"):
19
+ db_url = db_url.replace("postgresql://", "postgresql+psycopg://", 1)
20
+
21
+ self.schema = os.environ.get('OPENREMOTE_SCHEMA', 'openremote')
22
+ self.engine = create_engine(db_url, pool_pre_ping=True)
23
+ self.SessionLocal = sessionmaker(bind=self.engine)
24
+
25
+ self._cache: List[Dict[str, Any]] = []
26
+ self._mappings: Dict[str, Dict[str, Any]] = {} # id -> asset
27
+ self._name_map: Dict[str, str] = {} # normalized_name -> id
28
+ self._last_load = 0
29
+ self._ttl = 300 # 5 minutes
30
+ self._user_cache: Dict[str, List[Dict[str, Any]]] = {} # user_id -> allowed_assets
31
+ self._user_last_load: Dict[str, float] = {}
32
+
33
+ def _load_user_assets(self, user_id: str):
34
+ now = time.time()
35
+ if user_id in self._user_cache and (now - self._user_last_load.get(user_id, 0) < self._ttl):
36
+ return
37
+
38
+ query = text(f"""
39
+ WITH RECURSIVE hierarchy AS (
40
+ SELECT a.id::text, a.name, a.parent_id::text, a.attributes::jsonb AS attributes
41
+ FROM {self.schema}.asset a
42
+ JOIN {self.schema}.user_asset_link ual ON a.id = ual.asset_id
43
+ WHERE ual.user_id = :user_id
44
+
45
+ UNION
46
+
47
+ SELECT child.id::text, child.name, child.parent_id::text, child.attributes::jsonb AS attributes
48
+ FROM {self.schema}.asset child
49
+ JOIN hierarchy h ON child.parent_id = h.id
50
+ )
51
+ SELECT DISTINCT id, name, parent_id, attributes
52
+ FROM hierarchy
53
+ ORDER BY name
54
+ """)
55
+
56
+ try:
57
+ with self.SessionLocal() as session:
58
+ result = session.execute(query, {"user_id": user_id})
59
+ assets = [dict(row) for row in result.mappings().all()]
60
+
61
+ self._user_cache[user_id] = assets
62
+ self._user_last_load[user_id] = now
63
+ logger.info(f"Asset Registry: Loaded {len(assets)} assets for user {user_id}.")
64
+ except Exception as e:
65
+ logger.error(f"Asset Registry: Failed to load assets for user {user_id}: {e}")
66
+ self._user_cache[user_id] = []
67
+
68
+ # def normalize_name(self, name: str) -> str:
69
+ # """Standardizes name for fuzzy matching."""
70
+ # if not name: return ""
71
+ # # Remove special characters and spaces, lowercase
72
+ # return re.sub(r'[^a-zA-Z0-9]', '', name).lower()
73
+
74
+ def normalize_name(self, name: str) -> str:
75
+ """Standardizes name for fuzzy matching."""
76
+ if not name: return ""
77
+ # Remove special characters and spaces, lowercase
78
+ normalized = re.sub(r'[^a-zA-Z0-9]', '', name).lower()
79
+ # Remove leading zeros from numbers (e.g. coldroom01 -> coldroom1, but keeps 102 as 102)
80
+ normalized = re.sub(r'(?<!\d)0+(\d+)', r'\1', normalized)
81
+ return normalized
82
+
83
+
84
+ def get_summary(self, user_id: str) -> Dict[str, Any]:
85
+ """Returns a statistical summary of assets allowed for the user."""
86
+ self._load_user_assets(user_id)
87
+ assets = self._user_cache.get(user_id, [])
88
+
89
+ stats = {
90
+ "Total": len(assets),
91
+ "Tanks": 0,
92
+ "ColdRooms": 0,
93
+ "Machines": 0,
94
+ "Boilers": 0,
95
+ "SmokeAlarms": 0,
96
+ "Other": 0
97
+ }
98
+
99
+ for a in assets:
100
+ name = a['name'].lower()
101
+ if 'tank' in name or 'physical' in name: stats["Tanks"] += 1
102
+ elif 'cold' in name: stats["ColdRooms"] += 1
103
+ elif 'machine' in name or 'filling' in name: stats["Machines"] += 1
104
+ elif 'boiler' in name: stats["Boilers"] += 1
105
+ elif 'smoke' in name or 'alarm' in name: stats["SmokeAlarms"] += 1
106
+ else: stats["Other"] += 1
107
+
108
+ return stats
109
+
110
+ def _is_general_status_query(self, query: str) -> bool:
111
+ normalized = "".join(query.lower().split())
112
+ for kw in ["systemstatus", "statusofall", "allassets", "alldevices", "completestatus", "statusreport"]:
113
+ if kw in normalized:
114
+ return True
115
+ return False
116
+
117
+ def is_prefix_number_code(self, name: str) -> bool:
118
+ normalized = re.sub(r'[^a-zA-Z0-9]', '', name).lower()
119
+ return bool(re.match(r'^[a-zA-Z]+\d+$', normalized))
120
+
121
+ def is_asset_mentioned(self, asset_name: str, query: str) -> bool:
122
+ """
123
+ Robust check if asset_name is mentioned in the query,
124
+ respecting word boundaries and avoiding substring overlap (e.g. "st1" inside "past10").
125
+ """
126
+ q = query.lower()
127
+ an = asset_name.lower()
128
+
129
+ # 1. Exact or direct substring with word boundaries in original query
130
+ words = re.findall(r'[a-zA-Z]+|\d+', an)
131
+ if not words:
132
+ return False
133
+
134
+ # Build a regex pattern that matches the words in order, allowing optional spaces/hyphens/underscores
135
+ pattern_parts = []
136
+ for i, w in enumerate(words):
137
+ pattern_parts.append(re.escape(w))
138
+ if i < len(words) - 1:
139
+ pattern_parts.append(r'\s*[-_]?\s*')
140
+
141
+ pattern = r'\b' + "".join(pattern_parts) + r'\b'
142
+
143
+ try:
144
+ if re.search(pattern, q):
145
+ return True
146
+ except Exception:
147
+ pass
148
+
149
+ # 1b. Check prefix-stripped suffix matching (e.g., "ST1 Weather Station" matches when query has "weather station")
150
+ match = re.match(r'^([a-zA-Z]+\d+)\s+(.+)$', asset_name.strip())
151
+ if match:
152
+ suffix = match.group(2)
153
+ if len(suffix) > 3:
154
+ if self.is_asset_mentioned(suffix, query):
155
+ return True
156
+
157
+ # 2. Normalized fallback (only for names longer than 4 characters to avoid ST1/F1 false positives)
158
+ norm_q = self.normalize_name(q)
159
+ norm_an = self.normalize_name(an)
160
+ if len(norm_an) > 4 and not self.is_prefix_number_code(an) and norm_an in norm_q:
161
+ return True
162
+
163
+ return False
164
+
165
+ def find_mentioned_assets(self, query_name: str, user_id: str) -> List[Dict[str, Any]]:
166
+ self._load_user_assets(user_id)
167
+ allowed_assets = self._user_cache.get(user_id, [])
168
+
169
+ is_general_status = self._is_general_status_query(query_name)
170
+ mentioned = []
171
+
172
+ for a in allowed_assets:
173
+ if is_general_status and a['name'].lower() == 'status':
174
+ continue
175
+ if self.is_asset_mentioned(a['name'], query_name):
176
+ mentioned.append(a)
177
+
178
+ # If both a physical/non-Status asset and 'Status' are matched, discard 'Status'
179
+ if len(mentioned) >= 2:
180
+ has_physical = any(a['name'].lower() != 'status' for a in mentioned)
181
+ if has_physical:
182
+ mentioned = [a for a in mentioned if a['name'].lower() != 'status']
183
+
184
+ return mentioned
185
+
186
+ def find_asset(self, query_name: str, user_id: str) -> Optional[Dict[str, Any]]:
187
+ """Finds an asset by name using robust matching, filtered by user permissions."""
188
+ self._load_user_assets(user_id)
189
+ allowed_assets = self._user_cache.get(user_id, [])
190
+
191
+ # ---------------------------------------------------------------
192
+ # PRE-RESOLUTION: "Tank N" -> "physicalNN" (zero-padded, 2 digits)
193
+ # This mirrors the same rule in agents/live/system_message.md so
194
+ # that both the Live path and the Report path resolve to the same
195
+ # physical asset regardless of which path processes the message.
196
+ # ---------------------------------------------------------------
197
+ # tank_alias_match = re.search(r'\btank\s*(\d+)\b', query_name, re.IGNORECASE)
198
+ # if tank_alias_match:
199
+ # tank_num = int(tank_alias_match.group(1))
200
+ # physical_name = f'physical{tank_num:02d}' # e.g. physical11
201
+ # for a in allowed_assets:
202
+ # if self.normalize_name(a['name']) == physical_name:
203
+ # logger.debug(f"Asset Registry: 'Tank {tank_num}' pre-resolved to '{a['name']}' ({a['id']})")
204
+ # return a
205
+
206
+ # 1. Multi-tank comparison check
207
+ tank_matches = re.findall(r'\btank\s*(\d+)\b', query_name, re.IGNORECASE)
208
+ if len(tank_matches) >= 2:
209
+ matched_assets = []
210
+ for t_num in tank_matches:
211
+ physical_name = f'physical{int(t_num):02d}'
212
+ for a in allowed_assets:
213
+ if self.normalize_name(a['name']) == self.normalize_name(physical_name):
214
+ matched_assets.append(a)
215
+ break
216
+ # Multiple allowed tanks
217
+ if len(matched_assets) >= 2:
218
+ parent_ids = {a.get('parent_id') for a in matched_assets if a.get('parent_id')}
219
+ if len(parent_ids) == 1:
220
+ parent_id = list(parent_ids)[0]
221
+ parent_asset = self.get_asset_by_id(parent_id, user_id)
222
+ if parent_asset:
223
+ logger.info(f"Asset Registry: Multi-tank comparison detected. Mapping to common parent '{parent_asset['name']}' ({parent_asset['id']})")
224
+ return parent_asset
225
+
226
+ # 2. General Multi-asset comparison check (For Coldrooms, Machines, etc.)
227
+ mentioned = self.find_mentioned_assets(query_name, user_id)
228
+ if len(mentioned) >= 2:
229
+ parent_ids = {a.get('parent_id') for a in mentioned if a.get('parent_id')}
230
+ if len(parent_ids) == 1:
231
+ parent_id = list(parent_ids)[0]
232
+ parent_asset = self.get_asset_by_id(parent_id, user_id)
233
+ if parent_asset:
234
+ logger.info(f"Asset Registry: Multi-asset comparison detected. Mapping to common parent '{parent_asset['name']}' ({parent_asset['id']})")
235
+ return parent_asset
236
+
237
+ # 2. Single tank fallback
238
+ tank_alias_match = re.search(r'\btank\s*(\d+)\b', query_name, re.IGNORECASE)
239
+ if tank_alias_match:
240
+ tank_num = int(tank_alias_match.group(1))
241
+ physical_name = f'physical{tank_num:02d}' # e.g. physical11
242
+ for a in allowed_assets:
243
+ if self.normalize_name(a['name']) == self.normalize_name(physical_name):
244
+ logger.debug(f"Asset Registry: 'Tank {tank_num}' pre-resolved to '{a['name']}' ({a['id']})")
245
+ return a
246
+
247
+
248
+ normalized_query = self.normalize_name(query_name)
249
+ if not normalized_query:
250
+ return None
251
+
252
+ is_general_status = self._is_general_status_query(query_name)
253
+ is_tank = any(k in normalized_query for k in ['tank', 'physical', 'refinery']) or bool(re.search(r'\bst\s*\d+\b', query_name.lower())) or bool(re.search(r'\bst\d+\b', normalized_query))
254
+ is_cold = 'cold' in normalized_query
255
+ is_boiler = 'boiler' in normalized_query
256
+ is_machine = any(k in normalized_query for k in ['machine', 'filling'])
257
+
258
+ # Find target number in query (excluding time/limits)
259
+ num_matches = re.finditer(r'\b(\d+)\b', query_name)
260
+ target_num = None
261
+ for m in num_matches:
262
+ val = int(m.group(1))
263
+ start, end = m.span()
264
+ surrounding = query_name[max(0, start - 15):min(len(query_name), end + 15)].lower()
265
+ if re.search(
266
+ r'\b\d+\s*(?:minute|min|hour|hr|day|week|month|read(?:ing|ngs?|ings?)|row|log|point|datapoint)s?\b',
267
+ surrounding
268
+ ):
269
+ continue
270
+ target_num = val
271
+ break
272
+
273
+ if target_num is None:
274
+ num_match = re.search(r'(\d+)', normalized_query)
275
+ target_num = int(num_match.group(1)) if num_match else None
276
+
277
+ candidate_scores = []
278
+ for a in allowed_assets:
279
+ if is_general_status and a['name'].lower() == 'status':
280
+ continue
281
+
282
+ name = a['name']
283
+ db_name_lower = name.lower()
284
+ normalized_db_name = self.normalize_name(name)
285
+ if not normalized_db_name:
286
+ continue
287
+
288
+ score = 0.0
289
+
290
+ # Rule 1: Exact Match (1.0)
291
+ if normalized_db_name == normalized_query:
292
+ score = 1.0
293
+
294
+ # Rule 2: Category + Number Match (0.98)
295
+ elif target_num is not None:
296
+ db_num_match = re.search(r'(\d+)', db_name_lower)
297
+ if db_num_match and int(db_num_match.group(1)) == target_num:
298
+ cleaned_db = re.sub(r'[\s_-]', '', db_name_lower)
299
+ is_db_tank = 'tank' in db_name_lower or 'physical' in db_name_lower or 'refinery' in db_name_lower or bool(re.match(r'^st\d+$', cleaned_db))
300
+
301
+ category_matched = False
302
+ if is_tank and is_db_tank:
303
+ category_matched = True
304
+ elif is_cold and 'cold' in db_name_lower:
305
+ category_matched = True
306
+ elif is_boiler and 'boiler' in db_name_lower:
307
+ category_matched = True
308
+ elif is_machine and ('machine' in db_name_lower or 'filling' in db_name_lower):
309
+ category_matched = True
310
+
311
+ if category_matched:
312
+ score = 0.98
313
+
314
+ # Rule 3: Explicit Mention (0.95)
315
+ if score == 0.0:
316
+ if self.is_asset_mentioned(name, query_name):
317
+ cleaned_db = re.sub(r'[\s_-]', '', db_name_lower)
318
+ is_db_tank = 'tank' in db_name_lower or 'physical' in db_name_lower or 'refinery' in db_name_lower or bool(re.match(r'^st\d+$', cleaned_db))
319
+
320
+ category_conflict = False
321
+ if is_tank and not is_db_tank:
322
+ category_conflict = True
323
+ elif is_cold and 'cold' not in db_name_lower:
324
+ category_conflict = True
325
+ elif is_boiler and 'boiler' not in db_name_lower:
326
+ category_conflict = True
327
+ elif is_machine and not any(k in db_name_lower for k in ['machine', 'filling']):
328
+ category_conflict = True
329
+
330
+ if not category_conflict:
331
+ score = 0.95
332
+
333
+ # Rule 4: General Category Match or Suffix removed match (0.70)
334
+ if score == 0.0:
335
+ is_general_category = (is_cold and 'cold' in db_name_lower) or \
336
+ (is_tank and ('tank' in db_name_lower or 'physical' in db_name_lower)) or \
337
+ (is_boiler and 'boiler' in db_name_lower) or \
338
+ (is_machine and ('machine' in db_name_lower or 'filling' in db_name_lower))
339
+ if is_general_category:
340
+ score = 0.70
341
+ else:
342
+ for suffix in ['tank', 'asset', 'sensor', 'machine', 'boiler', 'coldroom']:
343
+ if normalized_query.endswith(suffix):
344
+ short = normalized_query[:-len(suffix)]
345
+ if normalized_db_name == short:
346
+ score = 0.70
347
+ break
348
+
349
+ # Rule 5: Generic Substring Match (0.60)
350
+ if score == 0.0:
351
+ if len(normalized_query) > 3 and normalized_db_name not in ['oil', 'water', 'status', 'temp', 'level']:
352
+ if normalized_query in normalized_db_name or normalized_db_name in normalized_query:
353
+ score = 0.60
354
+
355
+ if score > 0.0:
356
+ candidate_scores.append((a, score))
357
+
358
+ if not candidate_scores:
359
+ return None
360
+
361
+ # Sort candidate scores descending by score value
362
+ candidate_scores.sort(key=lambda x: x[1], reverse=True)
363
+ best_match, best_score = candidate_scores[0]
364
+
365
+ logger.info(f"Asset Registry: Best candidate '{best_match['name']}' with score {best_score}")
366
+
367
+ if best_score < 0.95:
368
+ logger.info(f"Asset Registry: Best score {best_score} is below 95% threshold. Asking counter-question.")
369
+ return None
370
+
371
+ # Check for ties on the highest score
372
+ ties = [item for item in candidate_scores if item[1] == best_score]
373
+ if len(ties) > 1:
374
+ logger.info(f"Asset Registry: Tie detected between {len(ties)} assets at score {best_score}. Asking counter-question.")
375
+ return None
376
+
377
+ return best_match
378
+
379
+ def get_asset_by_id(self, asset_id: str, user_id: str) -> Optional[Dict[str, Any]]:
380
+ self._load_user_assets(user_id)
381
+ for a in self._user_cache.get(user_id, []):
382
+ if a['id'] == asset_id:
383
+ return a
384
+ return None
385
+
386
+ def list_all_assets(self, user_id: str) -> List[Dict[str, Any]]:
387
+ self._load_user_assets(user_id)
388
+ return self._user_cache.get(user_id, [])
389
+
390
+ asset_registry = AssetRegistry()
SuperAgent/app/services/email_service.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import smtplib
3
+ from email.mime.multipart import MIMEMultipart
4
+ from email.mime.text import MIMEText
5
+ from email.mime.base import MIMEBase
6
+ from email import encoders
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class EmailService:
12
+ def __init__(self):
13
+ self.smtp_host = os.getenv("SMTP_HOST")
14
+ self.smtp_port = os.getenv("SMTP_PORT")
15
+ self.smtp_user = os.getenv("SMTP_USER")
16
+ self.smtp_pass = os.getenv("SMTP_PASSWORD")
17
+ self.smtp_from = os.getenv("SMTP_FROM", "[EMAIL_ADDRESS]")
18
+
19
+ def send_report(self, recipient: str, subject: str, body_html: str, attachment_path: str = None) -> bool:
20
+ if not recipient:
21
+ logger.warning("Email recipient is empty. Skipping email delivery.")
22
+ return False
23
+
24
+ if not self.smtp_host or not self.smtp_user or not self.smtp_pass:
25
+ logger.warning("SMTP credentials are not configured in environment. Printing email to console (MOCK).")
26
+ print(f"\n================ MOCK EMAIL SENT ================")
27
+ print(f"To: {recipient}")
28
+ print(f"From: {self.smtp_from}")
29
+ print(f"Subject: {subject}")
30
+ print(f"Body: {body_html[:300]}...")
31
+ if attachment_path:
32
+ print(f"Attachment: {os.path.basename(attachment_path)}")
33
+ print(f"=================================================\n")
34
+ return True
35
+
36
+ try:
37
+ # Create message container
38
+ msg = MIMEMultipart()
39
+ msg['From'] = self.smtp_from
40
+ msg['To'] = recipient
41
+ msg['Subject'] = subject
42
+
43
+ # Record the MIME types of both parts - text/html
44
+ msg.attach(MIMEText(body_html, 'html'))
45
+
46
+ # Attach PDF if provided
47
+ if attachment_path and os.path.exists(attachment_path):
48
+ filename = os.path.basename(attachment_path)
49
+ with open(attachment_path, "rb") as attachment:
50
+ part = MIMEBase("application", "octet-stream")
51
+ part.set_payload(attachment.read())
52
+ encoders.encode_base64(part)
53
+ part.add_header(
54
+ "Content-Disposition",
55
+ f"attachment; filename={filename}",
56
+ )
57
+ msg.attach(part)
58
+
59
+ # Connect to SMTP server
60
+ port = int(self.smtp_port) if self.smtp_port else 587
61
+ server = smtplib.SMTP(self.smtp_host, port)
62
+ server.starttls()
63
+ server.login(self.smtp_user, self.smtp_pass)
64
+
65
+ # Send email
66
+ server.sendmail(self.smtp_from, recipient, msg.as_string())
67
+ server.quit()
68
+
69
+ logger.info(f"Email sent successfully to {recipient}")
70
+ return True
71
+
72
+ except Exception as e:
73
+ logger.error(f"Failed to send email to {recipient}: {str(e)}")
74
+ return False
75
+
76
+ email_service = EmailService()
SuperAgent/app/services/report_generator.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ # Dynamically append root folder of the project to sys.path to enable smooth resolution of agents package
5
+ root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
6
+ if root_dir not in sys.path:
7
+ sys.path.insert(0, root_dir)
8
+
9
+ # Import the core logic from the newly decoupled report agent service
10
+ from agents.report.app.services.report_generator import report_generator_service, ReportGeneratorService
11
+
12
+ # Maintain absolute backward compatibility for any consumers
13
+ __all__ = ["report_generator_service", "ReportGeneratorService"]
SuperAgent/app/services/router.py ADDED
@@ -0,0 +1,881 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import logging
4
+ import httpx
5
+ import datetime
6
+ from typing import Any, Literal, TypedDict, Optional, List, Dict
7
+ from langgraph.graph import END, START, StateGraph
8
+ from ..models.schemas import ChatRequest, ChatResponse
9
+ from groq import Groq
10
+ from .asset_registry import asset_registry
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ Intent = Literal["live", "analysis", "anomaly", "report"]
15
+
16
+
17
+ class SuperAgentState(TypedDict, total=False):
18
+ message: str # The actual question (after stripping the User_ID prefix)
19
+ raw_message: str # Original message exactly as received
20
+ session_id: str
21
+ user_id: str
22
+ user_authorized: bool # True once permission check passes
23
+ check_all: bool
24
+ force_retrain: bool
25
+ asset_id: str | None
26
+ asset_name: str | None
27
+ agent_calls: list[str]
28
+ intent: Intent
29
+ answer: str
30
+ analysis: Any
31
+ data: list[dict[str, Any]]
32
+ generated_sql: str | None
33
+ orchestration: list[str]
34
+ report_format: Literal["excel", "pdf"]
35
+ schema_name: str | None
36
+
37
+
38
+ class RouterService:
39
+ def __init__(self):
40
+ api_key = os.getenv("GROQ_API_KEY")
41
+ self.client = Groq(api_key=api_key) if api_key else None
42
+ self.model = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
43
+
44
+ # Hugging Face uses port 7860, locally we default to 8000
45
+ port = os.getenv("PORT", "8000")
46
+
47
+ self.live_agent_url = os.getenv("LIVE_AGENT_URL", f"http://127.0.0.1:{port}/live")
48
+ self.analysis_agent_url = os.getenv("ANALYSIS_AGENT_URL", f"http://127.0.0.1:{port}/analysis")
49
+ self.anomaly_agent_url = os.getenv("ANOMALY_AGENT_URL", f"http://127.0.0.1:{port}/anomaly")
50
+ self.telemetry_agent_url = os.getenv("TELEMETRY_AGENT_URL", f"http://127.0.0.1:{port}/telemetry")
51
+
52
+ # Bulletproof port rewrite for any loopback URLs
53
+ for attr in ["live_agent_url", "analysis_agent_url", "anomaly_agent_url", "telemetry_agent_url"]:
54
+ val = getattr(self, attr)
55
+ if "localhost" in val or "127.0.0.1" in val:
56
+ new_val = re.sub(r":\d+", f":{port}", val)
57
+ setattr(self, attr, new_val)
58
+
59
+ self.graph = self._build_graph()
60
+
61
+ def _build_graph(self):
62
+ graph = StateGraph(SuperAgentState)
63
+
64
+ # --- Permission gate (runs first) ---
65
+ graph.add_node("check_permissions", self._check_permissions_node)
66
+ graph.add_node("classify_intent", self._classify_node)
67
+ graph.add_node("route_live", self._route_live_node)
68
+ graph.add_node("route_analysis", self._route_analysis_node)
69
+ graph.add_node("route_anomaly", self._route_anomaly_node)
70
+ graph.add_node("route_report_intent", self._route_report_intent_node)
71
+ graph.add_node("generate_excel", self._generate_excel_node)
72
+ graph.add_node("generate_pdf", self._generate_pdf_node)
73
+
74
+ graph.add_edge(START, "check_permissions")
75
+ graph.add_conditional_edges(
76
+ "check_permissions",
77
+ self._route_after_permission_check,
78
+ {
79
+ "authorized": "classify_intent",
80
+ "denied": END, # Short-circuit — answer already set
81
+ },
82
+ )
83
+ graph.add_conditional_edges(
84
+ "classify_intent",
85
+ self._route_from_intent,
86
+ {
87
+ "live": "route_live",
88
+ "analysis": "route_analysis",
89
+ "anomaly": "route_anomaly",
90
+ "report": "route_report_intent",
91
+ },
92
+ )
93
+ graph.add_conditional_edges(
94
+ "route_report_intent",
95
+ self._route_from_report_format,
96
+ {
97
+ "excel": "generate_excel",
98
+ "pdf": "generate_pdf",
99
+ },
100
+ )
101
+ graph.add_edge("route_live", END)
102
+ graph.add_edge("route_analysis", END)
103
+ graph.add_edge("route_anomaly", END)
104
+ graph.add_edge("generate_excel", END)
105
+ graph.add_edge("generate_pdf", END)
106
+ return graph.compile()
107
+
108
+ async def classify_intent(self, message: str) -> Intent:
109
+ deterministic_intent = self._classify_with_rules(message)
110
+ if deterministic_intent is not None:
111
+ return deterministic_intent
112
+
113
+ if self.client is None:
114
+ return "live"
115
+
116
+ prompt = f"""
117
+ Classify this telemetry question for routing.
118
+
119
+ Return "report" when the user wants to generate, download, or create a report file,
120
+ especially when they mention downloading, exporting, or creating an excel, xlsx,
121
+ pdf, sheet, or document, or ask to "generate a report" for a specific asset.
122
+
123
+ Return "analysis" when the user asks for historical analysis, trend analysis,
124
+ reports, charts, graphs, summaries over a time period, comparisons,
125
+ averages/min/max over a window, scheduled analysis, or any
126
+ date/range-based request.
127
+
128
+ Return "anomaly" when the user asks for status, anomalies, faults, incidents,
129
+ spikes, drops, mentioned "foreman", or asks about coldrooms/tanks/smoke alarm system status,
130
+ what is happening, or how they are doing (e.g. fire safety, abnormal behavior).
131
+
132
+ Return "live" when the user asks for current/latest/realtime status,
133
+ current value, online/offline state, latest sensor reading, current tank
134
+ level, live alerts, or what is happening right now.
135
+
136
+ User Message: "{message}"
137
+
138
+ Return ONLY the word "live", "analysis", "anomaly", or "report".
139
+ """
140
+
141
+ response = self.client.chat.completions.create(
142
+ messages=[
143
+ {"role": "user", "content": prompt}
144
+ ],
145
+ model=self.model,
146
+ temperature=0,
147
+ max_tokens=10,
148
+ )
149
+
150
+ intent = response.choices[0].message.content.strip().lower()
151
+ if "anomaly" in intent:
152
+ return "anomaly"
153
+ if "analysis" in intent:
154
+ return "analysis"
155
+ if "report" in intent:
156
+ return "report"
157
+ return "live"
158
+
159
+ # ------------------------------------------------------------------
160
+ # Permission-check node
161
+ # ------------------------------------------------------------------
162
+
163
+ @staticmethod
164
+ def _parse_user_message(raw: str) -> tuple[str, str]:
165
+ """
166
+ Splits a message of the form ``User_ID: question``.
167
+
168
+ Returns (user_id_or_empty, question).
169
+ The separator is the *first* colon that follows a non-whitespace
170
+ token at the start of the string so that questions containing colons
171
+ are never accidentally split.
172
+ """
173
+ # Match <anything without whitespace> : <rest>
174
+ m = re.match(r'^(\S+?)\s*:\s*(.+)$', raw.strip(), re.DOTALL)
175
+ if m:
176
+ return m.group(1).strip(), m.group(2).strip()
177
+ return "", raw.strip()
178
+
179
+ async def _check_permissions_node(self, state: SuperAgentState) -> SuperAgentState:
180
+ """
181
+ First node in the graph.
182
+
183
+ 1. Parses ``User_ID: question`` out of raw_message.
184
+ 2. Resolves the real user UUID (falls back to DEFAULT_USER_ID when
185
+ the parsed prefix looks like the legacy placeholder ``user_1``).
186
+ 3. Loads the user's allowed assets from the registry.
187
+ 4. Blocks the request (sets ``user_authorized=False``) when the user
188
+ has zero assets, indicating they don't exist in the system.
189
+ """
190
+ raw = state.get("raw_message") or state.get("message", "")
191
+ default_uid = os.getenv("DEFAULT_USER_ID", "ad7103e4-9595-4000-a56a-8f650e3eddef")
192
+
193
+ parsed_uid, question = self._parse_user_message(raw)
194
+ orch = state.get("orchestration", [])
195
+
196
+ # Resolve real UUID
197
+ if parsed_uid and parsed_uid not in ("", "user_1"):
198
+ user_id = parsed_uid
199
+ orch.append(f"Gateway: Parsed User_ID='{user_id}' from message prefix")
200
+ else:
201
+ user_id = state.get("user_id")
202
+ if not parsed_uid:
203
+ orch.append("Gateway: No User_ID prefix found — using session/default user")
204
+
205
+ # Check permissions via asset registry
206
+ allowed_assets = asset_registry.list_all_assets(user_id)
207
+
208
+ if not allowed_assets:
209
+ logger.warning(f"Permission denied: user '{user_id}' has no authorized assets.")
210
+ orch.append(f"Gateway: ❌ Permission denied — user '{user_id}' not found or has no assets.")
211
+ return {
212
+ "user_id": user_id,
213
+ "message": question,
214
+ "raw_message": raw,
215
+ "user_authorized": False,
216
+ "orchestration": orch,
217
+ "answer": (
218
+ f"⛔ Access denied. The user **`{user_id}`** does not exist in the system "
219
+ "or has no assets assigned. Please verify your User ID and try again."
220
+ ),
221
+ "intent": "live",
222
+ }
223
+
224
+ orch.append(
225
+ f"Gateway: ✅ User '{user_id}' authorized — {len(allowed_assets)} asset(s) available."
226
+ )
227
+ return {
228
+ "user_id": user_id,
229
+ "message": question,
230
+ "raw_message": raw,
231
+ "user_authorized": True,
232
+ "orchestration": orch,
233
+ }
234
+
235
+ def _route_after_permission_check(self, state: SuperAgentState) -> str:
236
+ return "authorized" if state.get("user_authorized", False) else "denied"
237
+
238
+ # ------------------------------------------------------------------
239
+ # Intent classification node
240
+ # ------------------------------------------------------------------
241
+
242
+ async def _classify_node(self, state: SuperAgentState) -> SuperAgentState:
243
+ intent = await self.classify_intent(state["message"])
244
+
245
+ is_all_query = any(kw in state["message"].lower() for kw in ["all assets", "show assets", "list assets", "summary"])
246
+
247
+ asset_id = None
248
+ asset_name = None
249
+
250
+ # 1. First Priority: Try to resolve from the message query itself
251
+ if not is_all_query:
252
+ asset = asset_registry.find_asset(state["message"], state["user_id"])
253
+ if asset:
254
+ asset_id = asset['id']
255
+ asset_name = asset['name']
256
+
257
+ orch = state.get("orchestration", [])
258
+ orch.append(f"Gateway: Intent classified as '{intent}'")
259
+
260
+ if is_all_query:
261
+ summary = asset_registry.get_summary(state["user_id"])
262
+ orch.append(f"Gateway: 'All Assets' query detected. System Summary: {summary}")
263
+ elif asset_id:
264
+ orch.append(f"Gateway: Identified asset '{asset_name}' ({asset_id})")
265
+
266
+ return {
267
+ "intent": intent or "live",
268
+ "asset_id": asset_id,
269
+ "asset_name": asset_name,
270
+ "orchestration": orch
271
+ }
272
+
273
+ def _route_from_intent(self, state: SuperAgentState) -> Intent:
274
+ return state.get("intent", "live")
275
+
276
+ async def _route_live_node(self, state: SuperAgentState) -> SuperAgentState:
277
+ orch = state.get("orchestration", [])
278
+ orch.append("Gateway -> Live Agent: Fetching real-time telemetry from database")
279
+ live_payload = {
280
+ "session_id": state.get("session_id") or "default",
281
+ "user_id": state.get("user_id"),
282
+ "message": state["message"],
283
+ "asset_id": state.get("asset_id"),
284
+ "asset_name": state.get("asset_name"),
285
+ "schema_name": state.get("schema_name"),
286
+ }
287
+ async with httpx.AsyncClient(timeout=300.0) as client:
288
+ response = await client.post(f"{self.telemetry_agent_url}/api/v1/chat/message", json=live_payload)
289
+ if response.status_code != 200:
290
+ orch.append("Live Agent: Failed to respond")
291
+ print(f"\n [GATEWAY ERROR] Live Route Failed: {response.text}\n")
292
+ return {
293
+ # "answer": f"Live Agent Error: {response.text}",
294
+ "answer": "The Gateway encountered an error communicating with the live telemetry service. Please try again.",
295
+ "intent": "live",
296
+ "orchestration": orch
297
+ }
298
+ data = response.json()
299
+ orch.append("Live Agent: Response received successfully")
300
+
301
+ return {
302
+ "answer": data.get("answer", ""),
303
+ "intent": "live",
304
+ "data": data.get("rows", []),
305
+ "generated_sql": data.get("generated_sql"),
306
+ "orchestration": orch
307
+ }
308
+
309
+ async def _route_analysis_node(self, state: SuperAgentState) -> SuperAgentState:
310
+ orch = state.get("orchestration", [])
311
+ orch.append("Gateway -> Analysis Agent: Initiating historical trend analysis")
312
+ analysis_payload = {
313
+ "message": state["message"],
314
+ "user_id": state.get("user_id"),
315
+ "asset_id": state.get("asset_id"),
316
+ "asset_name": state.get("asset_name"),
317
+ "schema_name": state.get("schema_name"),
318
+ }
319
+ async with httpx.AsyncClient(timeout=300.0) as client:
320
+ response = await client.post(
321
+ f"{self.telemetry_agent_url}/api/v1/chat/message",
322
+ json=analysis_payload,
323
+ )
324
+ if response.status_code != 200:
325
+ orch.append("Analysis Agent: Failed to respond")
326
+ print(f"\n [GATEWAY ERROR] Analysis Route Failed: {response.text}\n")
327
+ return {
328
+ # "answer": f"Analysis Agent Error: {response.text}",
329
+ "answer": "The Gateway encountered an error communicating with the analysis service. Please try again.",
330
+ "intent": "analysis",
331
+ "orchestration": orch
332
+ }
333
+ data = response.json()
334
+ orch.append("Analysis Agent: Trend analysis complete")
335
+
336
+ return {
337
+ "answer": data.get("answer", ""),
338
+ "intent": "analysis",
339
+ "analysis": data.get("analysis"),
340
+ "orchestration": orch
341
+ }
342
+
343
+ async def _route_anomaly_node(self, state: SuperAgentState) -> SuperAgentState:
344
+ orch = state.get("orchestration", [])
345
+ orch.append("Gateway -> Anomaly Agent (The Foreman): Activating multi-agent industrial supervisor")
346
+ anomaly_payload = {
347
+ "message": state["message"],
348
+ "user_id": state.get("user_id"),
349
+ "asset_id": state.get("asset_id"),
350
+ "asset_name": state.get("asset_name"),
351
+ "schema_name": state.get("schema_name"),
352
+ }
353
+ async with httpx.AsyncClient(timeout=600.0) as client:
354
+ response = await client.post(
355
+ f"{self.anomaly_agent_url}/api/chat",
356
+ json=anomaly_payload,
357
+ )
358
+ if response.status_code != 200:
359
+ orch.append("Anomaly Agent: Failed to respond")
360
+ return {
361
+ "answer": f"Anomaly Agent Error: {response.text}",
362
+ "intent": "anomaly",
363
+ "orchestration": orch
364
+ }
365
+ data = response.json()
366
+ orch.append("Anomaly Agent: Multi-agent reasoning cycle finished")
367
+
368
+ return {
369
+ "answer": data.get("reply", ""),
370
+ "intent": "anomaly",
371
+ "data": data.get("data"),
372
+ "orchestration": orch
373
+ }
374
+
375
+ def _classify_with_rules(self, message: str) -> Intent | None:
376
+ text = self._normalize_text(message)
377
+
378
+ # Check for report generation intent
379
+ report_keywords = [
380
+ "generate report", "download report", "get report", "create report",
381
+ "excel report", "pdf report", "xlsx report", "sheet report", "spreadsheet report",
382
+ "generate excel", "generate pdf", "download excel", "download pdf",
383
+ "save as pdf", "save as excel", "export to pdf", "export to excel",
384
+ "export pdf", "export excel", "make pdf", "make excel", "telemetry pdf", "telemetry excel",
385
+ "pdf file", "excel file", "xlsx file", "spreadsheet file", "sheet file",
386
+ "pdf", "excel", "xlsx", "spreadsheet", "csv"
387
+ ]
388
+ if any(kw in text for kw in report_keywords):
389
+ return "report"
390
+
391
+ # Route coldrooms/tanks/smoke alarm system status, what is happening,
392
+ # and how they are doing to "anomaly" so that the anomaly agent is run.
393
+ has_specific_asset = any(term in text for term in [
394
+ "coldroom", "cold room", "coldrooms", "cold rooms",
395
+ "tank", "tanks",
396
+ "smoke alarm", "smoke alarms", "smoke detector", "smoke detectors",
397
+ "smoke sensor", "smoke sensors", "fire alarm", "fire alarms"
398
+ ])
399
+ if has_specific_asset:
400
+ is_status_query = "status" in text or "state" in text
401
+ is_happening_query = "happening" in text or "going on" in text or "what is with" in text
402
+ is_doing_query = (
403
+ "doing" in text or
404
+ "how is" in text or
405
+ "how are" in text or
406
+ "how's" in text or
407
+ "is ok" in text or
408
+ "is okay" in text or
409
+ "are ok" in text or
410
+ "are okay" in text
411
+ )
412
+ if is_status_query or is_happening_query or is_doing_query:
413
+ is_global = any(w in text for w in [
414
+ "all", "every", "scan", "system", "saare", "incidents",
415
+ "tanks", "coldrooms", "zones", "alarms", "detectors", "sensors"
416
+ ])
417
+ has_number = bool(re.search(r'\d+', text))
418
+ if is_global or has_number:
419
+ return "anomaly"
420
+
421
+ # Force "live" routing for global asset status summaries
422
+ global_status_keywords = ["systemstatus", "statusofall", "allassetsstatus", "alldevicesstatus", "completestatus", "statusreport"]
423
+ if any(kw in text.replace(" ", "") for kw in global_status_keywords):
424
+ return "live"
425
+
426
+ analysis_phrases = (
427
+ "analysis",
428
+ "analyze",
429
+ "analyse",
430
+ "trend",
431
+ "history",
432
+ "historical",
433
+ "report",
434
+ "graph",
435
+ "chart",
436
+ "compare",
437
+ "comparison",
438
+ "average",
439
+ "avg",
440
+ "minimum",
441
+ "maximum",
442
+ "variance",
443
+ "stability",
444
+ "stable",
445
+ "performance",
446
+ "efficiency",
447
+ "downtime",
448
+ "summary",
449
+ "summarize",
450
+ "scheduled analysis",
451
+ "schedule analysis",
452
+ "scheduled",
453
+ "forecast",
454
+ )
455
+ anomaly_phrases = (
456
+ "anomaly",
457
+ "anomalies",
458
+ "fault",
459
+ "faults",
460
+ "incident",
461
+ "incidents",
462
+ "spike",
463
+ "drop",
464
+ "suspicious",
465
+ "smoke",
466
+ "fire",
467
+ "foreman",
468
+ "abnormal",
469
+ "behavior",
470
+ "behaviour",
471
+ "faults",
472
+ "incidents",
473
+ "spikes",
474
+ "active alerts"
475
+ )
476
+ live_phrases = (
477
+ "right now",
478
+ "at the moment",
479
+ "currently",
480
+ "current",
481
+ "realtime",
482
+ "real time",
483
+ "real-time",
484
+ "live",
485
+ "latest",
486
+ "now",
487
+ "online",
488
+ "offline",
489
+ "status",
490
+ "state",
491
+ "value",
492
+ "reading",
493
+ "level",
494
+ "alert",
495
+ "alerts",
496
+ "show all",
497
+ "all assets",
498
+ "all devices",
499
+ "all attributes",
500
+ "end attributes",
501
+ "category",
502
+ "category assets",
503
+ "category devices",
504
+ )
505
+ time_window_patterns = (
506
+ r"\blast\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b",
507
+ r"\bpast\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b",
508
+ r"\bprevious\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b",
509
+ r"\bfor\s+(today|yesterday|tomorrow)\b",
510
+ r"\b(today|yesterday)\b",
511
+ r"\bbetween\b.+\band\b",
512
+ r"\bfrom\b.+\bto\b",
513
+ r"\bsince\b",
514
+ r"\bover\s+the\s+(last|past|previous)\b",
515
+ r"\b\d{1,2}(st|nd|rd|th)?\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\s+\d{4}\b",
516
+ r"\b\d{4}-\d{1,2}-\d{1,2}\b",
517
+ r"\b\d{1,2}/\d{1,2}/\d{2,4}\b",
518
+ )
519
+
520
+ has_analysis_phrase = any(phrase in text for phrase in analysis_phrases)
521
+ has_anomaly_phrase = any(phrase in text for phrase in anomaly_phrases)
522
+ has_live_phrase = any(phrase in text for phrase in live_phrases)
523
+ has_time_window = any(re.search(pattern, text) for pattern in time_window_patterns)
524
+
525
+ if has_anomaly_phrase:
526
+ return "anomaly"
527
+ if has_analysis_phrase or has_time_window:
528
+ return "analysis"
529
+ if has_live_phrase:
530
+ return "live"
531
+ return None
532
+
533
+ async def route_request(self, payload: ChatRequest) -> ChatResponse:
534
+ raw_uid = payload.user_id or ""
535
+ session_user_id = raw_uid if (raw_uid and raw_uid != "user_1") else None
536
+
537
+ parsed_uid, question = self._parse_user_message(payload.message)
538
+
539
+ if self._is_greeting(question):
540
+ # Intercept and return a polite, friendly greeting directly without database query
541
+ greeting_answer = await self._generate_greeting_response(question)
542
+ orch_steps = ["Gateway: Intercepted greeting message — responded directly without database query."]
543
+ print("\n🔄 System Orchestration:\n" + "\n".join([f"- {step}" for step in orch_steps]))
544
+ return ChatResponse(
545
+ answer=greeting_answer,
546
+ intent="live",
547
+ orchestration=None
548
+ )
549
+
550
+ # Centralized schema verification (Hardcoded to openremote to clean up JSON API)
551
+ schema_name = "openremote"
552
+
553
+ final_state = await self.graph.ainvoke(
554
+ {
555
+ # Pass the raw message untouched so the permission node can parse the prefix
556
+ "raw_message": payload.message,
557
+ "message": payload.message, # will be replaced by the permission node
558
+ "asset_id": payload.asset_id,
559
+ "session_id": payload.session_id or "default",
560
+ "user_id": session_user_id, # may be overridden by parsed prefix
561
+ "schema_name": schema_name,
562
+ "orchestration": []
563
+ }
564
+ )
565
+
566
+ answer = final_state.get("answer", "")
567
+ orch_steps = final_state.get("orchestration", [])
568
+ if orch_steps:
569
+ print("\n🔄 System Orchestration:\n" + "\n".join([f"- {step}" for step in orch_steps]))
570
+
571
+ return ChatResponse(
572
+ answer=answer,
573
+ intent=final_state.get("intent", "live"),
574
+ analysis=final_state.get("analysis"),
575
+ data=final_state.get("data"),
576
+ generated_sql=final_state.get("generated_sql"),
577
+ orchestration=None
578
+ )
579
+
580
+
581
+ def _is_greeting(self, message: str) -> bool:
582
+ # Normalize: convert to lower case, strip extra whitespace, and remove punctuation at start/end
583
+ text = message.strip().lower()
584
+ text_clean = re.sub(r"^[^\w]+|[^\w]+$", "", text) # remove non-alphanumeric at start/end
585
+
586
+ # Split into words to analyze length and content
587
+ words = text_clean.split()
588
+ if not words:
589
+ return False
590
+
591
+ # Common greeting words/phrases
592
+ greeting_words = {
593
+ "hi", 'hii','hiii', "hy", "hello", "hey", "hola", "yo", "greetings", "howdy", "helo", "hii", "heyy",
594
+ "salam", "assalamualaikum", "namaste", "wassup", "sup",
595
+ "ok", "okay", "thanks", "thankyou", "thank you", "nice", "good", "great", "fine", "cool", "perfect"
596
+ }
597
+
598
+ greeting_phrases = [
599
+ "good morning", "good afternoon", "good evening", "as salam alaykum", "whats up", "what's up",
600
+ "hi there", "hello there", "hey there", "how are you", "how r u", "how is it going", "how's it going",
601
+ "how are you today", "how are you doing", "hope you are doing well"
602
+ ]
603
+
604
+ # Check if the entire message is one of the greeting phrases
605
+ if any(phrase in text_clean for phrase in greeting_phrases):
606
+ # Check for telemetry keywords to prevent false positives
607
+ if not any(kw in text_clean for kw in self._get_telemetry_keywords()):
608
+ return True
609
+
610
+ # Check if the first word or the only word is a greeting word
611
+ # If it's a single-word message and it matches a greeting word, it's a greeting
612
+ if len(words) == 1 and words[0] in greeting_words:
613
+ return True
614
+
615
+ # If it's a short message (e.g. up to 4 words) starting with a greeting word and contains no telemetry keywords, it's a greeting
616
+ if len(words) <= 4 and words[0] in greeting_words:
617
+ if not any(kw in text_clean for kw in self._get_telemetry_keywords()):
618
+ return True
619
+
620
+ return False
621
+
622
+ def _get_telemetry_keywords(self) -> set[str]:
623
+ return {
624
+ "asset", "device", "tank", "coldroom", "cold room", "temperature", "temp", "oil", "level", "value",
625
+ "status", "online", "offline", "fault", "anomaly", "anomalies", "alarm", "smoke", "fire", "incident", "incidents",
626
+ "report", "graph", "chart", "analyze", "analysis", "trend", "history", "historical", "reading", "humidity",
627
+ "zone", "refinery", "foreman", "baseline", "sensor", "attribute", "attributes", "show all", "list", "query"
628
+ }
629
+
630
+ async def _generate_greeting_response(self, greeting: str) -> str:
631
+ if self.client:
632
+ try:
633
+ prompt = f"""
634
+ You are the Multi-Agent Telemetry SuperAgent. The user has greeted you with: "{greeting}".
635
+ Respond to their greeting in an extremely polite, warm, and professional way.
636
+ Keep it brief (2-3 sentences), and offer to help them monitor their IoT assets, analyze telemetry, or check for anomalies.
637
+ Do not query any databases or mention any specific asset data.
638
+ """
639
+ response = self.client.chat.completions.create(
640
+ messages=[{"role": "user", "content": prompt}],
641
+ model=self.model,
642
+ temperature=0.7,
643
+ max_tokens=150,
644
+ )
645
+ return response.choices[0].message.content.strip()
646
+ except Exception as e:
647
+ logger.error(f"Error generating greeting response with LLM: {e}")
648
+
649
+ # Fallback static response if LLM is unavailable or fails
650
+ return (
651
+ "Hello! I am your Multi-Agent Telemetry SuperAgent. 🌟\n\n"
652
+ "I'm here to help you monitor and analyze your IoT assets (like coldrooms and tanks) in real-time, "
653
+ "generate historical trend reports, or scan for anomalies with the Foreman.\n\n"
654
+ "How can I assist you today? Feel free to ask me questions like:\n"
655
+ "- *'What is the current status of all my assets?'*\n"
656
+ "- *'Compare the performance of tank 1 and tank 2 for yesterday.'*\n"
657
+ "- *'Are there any fire or smoke incidents in zone 3?'*"
658
+ )
659
+
660
+ async def _route_report_intent_node(self, state: SuperAgentState) -> SuperAgentState:
661
+ from .report_generator import report_generator_service
662
+ orch = state.get("orchestration", [])
663
+ fmt = report_generator_service.parse_format(state["message"])
664
+ orch.append(f"Report Agent: Identified format request as '{fmt.upper()}'")
665
+ return {
666
+ "report_format": fmt,
667
+ "orchestration": orch
668
+ }
669
+
670
+ def _route_from_report_format(self, state: SuperAgentState) -> str:
671
+ return state.get("report_format", "pdf")
672
+
673
+ async def _analyze_and_enrich_report(self, state: SuperAgentState) -> tuple[Optional[str], Optional[list[dict]], list[str]]:
674
+ """
675
+ Extracts the core query, runs the appropriate underlying agent (live, analysis, anomaly)
676
+ to get actual analytical findings and telemetry rows, and returns:
677
+ (agent_answer, telemetry_rows, orchestration_steps)
678
+ """
679
+ orch = state.get("orchestration", [])
680
+ original_msg = state["message"]
681
+
682
+ # 1. Extract core query
683
+ core_query = self._extract_core_query(original_msg)
684
+ orch.append(f"Report Agent: Extracted core query for analysis: '{core_query}'")
685
+
686
+ # 2. Classify intent of the core query
687
+ underlying_intent = await self.classify_intent(core_query)
688
+ if underlying_intent == "report":
689
+ # Fallback rules to avoid infinite loop
690
+ text = self._normalize_text(core_query)
691
+ if any(phrase in text for phrase in ["anomaly", "fault", "incident", "status", "doing"]):
692
+ underlying_intent = "anomaly"
693
+ elif any(phrase in text for phrase in ["trend", "history", "average", "avg", "last", "past"]):
694
+ underlying_intent = "analysis"
695
+ else:
696
+ underlying_intent = "live"
697
+
698
+ orch.append(f"Report Agent: Core query classified as '{underlying_intent}' intent")
699
+
700
+ agent_answer = None
701
+ telemetry_rows = None
702
+
703
+ # 3. Call the appropriate underlying agent
704
+ if underlying_intent == "live":
705
+ orch.append("Report Agent -> Routing core query to Live Agent...")
706
+ live_payload = {
707
+ "session_id": state.get("session_id") or "default",
708
+ "user_id": state.get("user_id"),
709
+ "message": core_query,
710
+ "asset_id": state.get("asset_id"),
711
+ "asset_name": state.get("asset_name"),
712
+ "schema_name": state.get("schema_name"),
713
+ }
714
+ try:
715
+ async with httpx.AsyncClient(timeout=300.0) as client:
716
+ response = await client.post(f"{self.telemetry_agent_url}/api/v1/chat/message", json=live_payload)
717
+ if response.status_code == 200:
718
+ data = response.json()
719
+ agent_answer = data.get("answer", "")
720
+ telemetry_rows = data.get("rows", [])
721
+ orch.append("Report Agent: Successfully retrieved real-time analysis and data from Live Agent")
722
+ else:
723
+ orch.append(f"Report Agent: Live Agent responded with status {response.status_code}")
724
+ except Exception as e:
725
+ orch.append(f"Report Agent: Error calling Live Agent: {str(e)}")
726
+
727
+ elif underlying_intent == "analysis":
728
+ orch.append("Report Agent -> Routing core query to Analysis Agent...")
729
+ analysis_payload = {
730
+ "message": core_query,
731
+ "user_id": state.get("user_id"),
732
+ "asset_id": state.get("asset_id"),
733
+ "asset_name": state.get("asset_name"),
734
+ "schema_name": state.get("schema_name"),
735
+ }
736
+ try:
737
+ async with httpx.AsyncClient(timeout=300.0) as client:
738
+ response = await client.post(f"{self.telemetry_agent_url}/api/v1/chat/message", json=analysis_payload)
739
+ if response.status_code == 200:
740
+ data = response.json()
741
+ agent_answer = data.get("answer", "")
742
+ # Try to extract data if present in analysis
743
+ analysis_data = data.get("analysis", {})
744
+ if isinstance(analysis_data, dict):
745
+ series = analysis_data.get("trend_chart", {}).get("series", [])
746
+ if series and isinstance(series, list):
747
+ rows = []
748
+ for s in series:
749
+ metric = s.get("label", "Telemetry")
750
+ for p in s.get("points", []):
751
+ rows.append({
752
+ "timestamp": p.get("label", "N/A"),
753
+ "metric": metric,
754
+ "value": p.get("value", 0),
755
+ "unit": "Units",
756
+ "status": "NORMAL"
757
+ })
758
+ if rows:
759
+ telemetry_rows = rows
760
+ orch.append("Report Agent: Successfully retrieved trend analysis from Analysis Agent")
761
+ else:
762
+ orch.append(f"Report Agent: Analysis Agent responded with status {response.status_code}")
763
+ except Exception as e:
764
+ orch.append(f"Report Agent: Error calling Analysis Agent: {str(e)}")
765
+
766
+ elif underlying_intent == "anomaly":
767
+ orch.append("Report Agent -> Routing core query to Anomaly Agent...")
768
+ anomaly_payload = {
769
+ "message": core_query,
770
+ "user_id": state.get("user_id"),
771
+ "asset_id": state.get("asset_id"),
772
+ "asset_name": state.get("asset_name"),
773
+ "schema_name": state.get("schema_name"),
774
+ }
775
+ try:
776
+ async with httpx.AsyncClient(timeout=600.0) as client:
777
+ response = await client.post(f"{self.anomaly_agent_url}/api/chat", json=anomaly_payload)
778
+ if response.status_code == 200:
779
+ data = response.json()
780
+ agent_answer = data.get("reply", "")
781
+ anomaly_data = data.get("data", [])
782
+ if anomaly_data and isinstance(anomaly_data, list):
783
+ telemetry_rows = []
784
+ for item in anomaly_data:
785
+ telemetry_rows.append({
786
+ "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
787
+ "metric": "Anomaly Status",
788
+ "value": item.get("value") or item.get("latest_mse", "N/A"),
789
+ "unit": "Status",
790
+ "status": item.get("status") or "CRITICAL"
791
+ })
792
+ orch.append("Report Agent: Successfully retrieved anomaly diagnosis from Anomaly Agent")
793
+ else:
794
+ orch.append(f"Report Agent: Anomaly Agent responded with status {response.status_code}")
795
+ except Exception as e:
796
+ orch.append(f"Report Agent: Error calling Anomaly Agent: {str(e)}")
797
+
798
+ return agent_answer, telemetry_rows, orch
799
+
800
+ def _extract_core_query(self, message: str) -> str:
801
+ msg = message
802
+ patterns = [
803
+ r"\b(?:generate|download|get|create|save|export|make|show)\s+(?:a\s+)?(?:stylized|premium|corporate|operational\s+)?(?:pdf|excel|xlsx|spreadsheet|csv|sheet|file|document)?(?:\s+report)?\s*(?:for|of|on|about)?\b",
804
+ r"\b(?:pdf|excel|xlsx|spreadsheet|csv|sheet|file|document)\s+report\s*(?:for|of|on|about)?\b",
805
+ r"\b(?:pdf|excel|xlsx|spreadsheet|csv|sheet|file|document)\s*(?:please|plz)?\b",
806
+ r"\b(?:please|plz)\s*(?:send|give|generate|download)\b",
807
+ ]
808
+ for pattern in patterns:
809
+ msg = re.sub(pattern, "", msg, flags=re.IGNORECASE)
810
+ msg = re.sub(r"\s+", " ", msg).strip()
811
+ msg = re.sub(r"^[?,.:;\s]+|[?,.:;\s]+$", "", msg)
812
+ return msg if msg else message
813
+
814
+ async def _generate_excel_node(self, state: SuperAgentState) -> SuperAgentState:
815
+ from .report_generator import report_generator_service
816
+ orch = state.get("orchestration", [])
817
+
818
+ # Pre-analyze core query
819
+ agent_answer, telemetry_rows, orch = await self._analyze_and_enrich_report(state)
820
+
821
+ orch.append("Report Agent -> Excel Generator: Compiling custom XLSX operational sheet")
822
+ res = report_generator_service.generate_report(
823
+ user_id=state.get("user_id"),
824
+ asset_query=state["message"],
825
+ format_type="excel",
826
+ custom_data=telemetry_rows,
827
+ executive_summary=agent_answer,
828
+ asset_id=state.get("asset_id")
829
+ )
830
+ orch.append("Excel Generator: File compiled successfully and link created")
831
+
832
+ final_answer = ""
833
+ if agent_answer:
834
+ clean_ans = agent_answer
835
+ if "**System Orchestration:**" in clean_ans:
836
+ clean_ans = clean_ans.split("**System Orchestration:**")[0].strip()
837
+ final_answer += f"### 🔍 Analytical Findings & Diagnosis\n{clean_ans}\n\n---\n\n"
838
+ final_answer += res["answer"]
839
+
840
+ return {
841
+ "answer": final_answer,
842
+ "intent": "report",
843
+ "orchestration": orch
844
+ }
845
+
846
+ async def _generate_pdf_node(self, state: SuperAgentState) -> SuperAgentState:
847
+ from .report_generator import report_generator_service
848
+ orch = state.get("orchestration", [])
849
+
850
+ # Pre-analyze core query
851
+ agent_answer, telemetry_rows, orch = await self._analyze_and_enrich_report(state)
852
+
853
+ orch.append("Report Agent -> PDF Generator: Generating stylized operational report document")
854
+ res = report_generator_service.generate_report(
855
+ user_id=state.get("user_id"),
856
+ asset_query=state["message"],
857
+ format_type="pdf",
858
+ custom_data=telemetry_rows,
859
+ executive_summary=agent_answer,
860
+ asset_id=state.get("asset_id")
861
+ )
862
+ orch.append("PDF Generator: PDF generated successfully and link created")
863
+
864
+ final_answer = ""
865
+ if agent_answer:
866
+ clean_ans = agent_answer
867
+ if "**System Orchestration:**" in clean_ans:
868
+ clean_ans = clean_ans.split("**System Orchestration:**")[0].strip()
869
+ final_answer += f"### 🔍 Analytical Findings & Diagnosis\n{clean_ans}\n\n---\n\n"
870
+ final_answer += res["answer"]
871
+
872
+ return {
873
+ "answer": final_answer,
874
+ "intent": "report",
875
+ "orchestration": orch
876
+ }
877
+
878
+ def _normalize_text(self, message: str) -> str:
879
+ return re.sub(r"\s+", " ", message.strip().lower())
880
+
881
+ router_service = RouterService()
SuperAgent/main.py ADDED
@@ -0,0 +1,970 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import re
4
+ import httpx
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Optional, List, Dict, Any
8
+
9
+ # Mute verbose httpx and httpcore INFO logs to keep console clean
10
+ logging.getLogger("httpx").setLevel(logging.WARNING)
11
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
12
+
13
+ # Add project root and sub-agent directories to sys.path to resolve dynamic package namespaces (Trigger Reload)
14
+ root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15
+ if root_dir not in sys.path:
16
+ sys.path.insert(0, root_dir)
17
+
18
+ sys.path.append(os.path.join(root_dir, "agents", "live"))
19
+ sys.path.append(os.path.join(root_dir, "agents", "analysis", "backend"))
20
+ sys.path.append(os.path.join(root_dir, "agents", "anomaly", "backend"))
21
+ sys.path.append(os.path.join(root_dir, "agents", "telemetry"))
22
+ sys.path.append(os.path.join(root_dir, "agents", "report"))
23
+
24
+ from fastapi import FastAPI, HTTPException, BackgroundTasks
25
+ from fastapi.middleware.cors import CORSMiddleware
26
+ from dotenv import load_dotenv
27
+
28
+ env_path = Path(__file__).parent.parent / ".env"
29
+ load_dotenv(dotenv_path=env_path, override=True)
30
+
31
+ from app.models.schemas import ChatRequest, ChatResponse, SessionCreateRequest, SessionTitleUpdateRequest
32
+ from app.services.router import router_service
33
+
34
+ from fastapi.responses import RedirectResponse, FileResponse
35
+ from fastapi.staticfiles import StaticFiles
36
+
37
+ app = FastAPI(title="Visibility AI Multi-Agent Gateway", version="0.1.0")
38
+
39
+ from sqlalchemy import create_engine, text
40
+ from sqlalchemy.orm import sessionmaker
41
+ def resolve_and_verify_supabase_url() -> str:
42
+ import socket
43
+ import re
44
+ import urllib.request
45
+ import json
46
+ import ssl
47
+
48
+ supabase_db_url = os.getenv("SUPABASE_DB_URL")
49
+ if not supabase_db_url:
50
+ raise ValueError("CRITICAL ERROR: SUPABASE_DB_URL environment variable is missing! Chat history requires a dedicated database configuration.")
51
+
52
+ # Clean pgbouncer parameter
53
+ if "pgbouncer=" in supabase_db_url:
54
+ supabase_db_url = re.sub(r'[?&]pgbouncer=[^&]+', '', supabase_db_url)
55
+
56
+ # Parse project ref and password
57
+ project_ref = "qwtpmkinqatjylozropu"
58
+ supabase_url = os.getenv("SUPABASE_URL", "")
59
+ match = re.search(r'https://([^.]+)\.supabase\.co', supabase_url)
60
+ if match:
61
+ project_ref = match.group(1)
62
+
63
+ password = "zaheerafzal"
64
+ pass_match = re.search(r'postgresql://[^:]+:([^@]+)@', supabase_db_url)
65
+ if pass_match:
66
+ password = pass_match.group(1)
67
+
68
+ # Try importing driver
69
+ connect_fn = None
70
+ try:
71
+ import psycopg
72
+ connect_fn = psycopg.connect
73
+ except ImportError:
74
+ try:
75
+ import psycopg2
76
+ connect_fn = psycopg2.connect
77
+ except ImportError:
78
+ pass
79
+
80
+ if not connect_fn:
81
+ print("SUPABASE CONNECTION: No postgres driver (psycopg/psycopg2) available for pre-verification.")
82
+ return supabase_db_url
83
+
84
+ # Helper function to resolve DNS over HTTPS (DoH) with SSL bypass
85
+ def doh_resolve(hostname: str) -> str:
86
+ ctx = ssl._create_unverified_context()
87
+ # Try Google DoH
88
+ try:
89
+ url = f"https://dns.google/resolve?name={hostname}&type=A"
90
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
91
+ with urllib.request.urlopen(req, timeout=3, context=ctx) as response:
92
+ data = json.loads(response.read().decode())
93
+ if "Answer" in data:
94
+ for answer in data["Answer"]:
95
+ if answer.get("type") == 1:
96
+ return answer.get("data")
97
+ except Exception as e:
98
+ print(f"SUPABASE CONNECTION: Google DoH failed for {hostname}: {e}")
99
+
100
+ # Try Cloudflare DoH
101
+ try:
102
+ url = f"https://cloudflare-dns.com/dns-query?name={hostname}&type=A"
103
+ req = urllib.request.Request(url, headers={"Accept": "application/dns-json"})
104
+ with urllib.request.urlopen(req, timeout=3, context=ctx) as response:
105
+ data = json.loads(response.read().decode())
106
+ if "Answer" in data:
107
+ for answer in data["Answer"]:
108
+ if answer.get("type") == 1:
109
+ return answer.get("data")
110
+ except Exception as e:
111
+ print(f"SUPABASE CONNECTION: Cloudflare DoH failed for {hostname}: {e}")
112
+ return None
113
+
114
+ # 1. Try resolving using system DNS first
115
+ host_match = re.search(r'@([^:/]+)', supabase_db_url)
116
+ if host_match:
117
+ configured_host = host_match.group(1)
118
+ resolved_ip = None
119
+ try:
120
+ resolved_ip = socket.gethostbyname(configured_host)
121
+ except Exception:
122
+ # Fallback to DoH if system DNS fails
123
+ print(f"SUPABASE CONNECTION: System DNS failed to resolve '{configured_host}'. Trying DNS-over-HTTPS...")
124
+ resolved_ip = doh_resolve(configured_host)
125
+
126
+ if resolved_ip:
127
+ test_url = supabase_db_url
128
+ if resolved_ip != configured_host:
129
+ test_url = supabase_db_url.replace(f"@{configured_host}", f"@{resolved_ip}")
130
+ try:
131
+ with connect_fn(test_url, connect_timeout=3) as conn:
132
+ print(f"SUPABASE CONNECTION: Connected successfully to host/IP: {resolved_ip}")
133
+ return test_url
134
+ except Exception as e:
135
+ print(f"SUPABASE CONNECTION: Connection to '{resolved_ip}' failed: {e}")
136
+
137
+ # 2. Try direct connection format (db.<project>.supabase.co:5432)
138
+ direct_host = f"db.{project_ref}.supabase.co"
139
+ resolved_direct_ip = None
140
+ try:
141
+ resolved_direct_ip = socket.gethostbyname(direct_host)
142
+ except Exception:
143
+ print(f"SUPABASE CONNECTION: System DNS failed to resolve direct host '{direct_host}'. Trying DNS-over-HTTPS...")
144
+ resolved_direct_ip = doh_resolve(direct_host)
145
+
146
+ # Hardcoded IP fallback for the specific project if all lookups fail
147
+ if not resolved_direct_ip and project_ref == "qwtpmkinqatjylozropu":
148
+ print(f"SUPABASE CONNECTION: All lookups failed. Using fallback IP for {project_ref}...")
149
+ resolved_direct_ip = "3.111.134.120"
150
+
151
+ if resolved_direct_ip:
152
+ direct_url = f"postgresql://postgres:{password}@{resolved_direct_ip}:5432/postgres"
153
+ try:
154
+ with connect_fn(direct_url, connect_timeout=3) as conn:
155
+ print(f"SUPABASE CONNECTION: Connected successfully to direct host IP: {resolved_direct_ip}")
156
+ return direct_url
157
+ except Exception as e:
158
+ print(f"SUPABASE CONNECTION: Connection to direct host IP '{resolved_direct_ip}' failed: {e}")
159
+
160
+ # If all fail, fallback to original configured URL so SQLAlchemy throws a clean error
161
+ print("SUPABASE CONNECTION: Auto-detection failed. Falling back to configured URL.")
162
+ return supabase_db_url
163
+
164
+
165
+ supabase_db_url = resolve_and_verify_supabase_url()
166
+ if supabase_db_url.startswith("postgresql://"):
167
+ supabase_db_url = supabase_db_url.replace("postgresql://", "postgresql+psycopg://", 1)
168
+
169
+ SupabaseEngine = create_engine(supabase_db_url, pool_pre_ping=True)
170
+ SupabaseSessionLocal = sessionmaker(bind=SupabaseEngine)
171
+ print("SUPABASE CHAT DB: Configured dedicated connection pool successfully.")
172
+
173
+ # Import sub-agents applications
174
+ from agents.live.app.main import app as live_app
175
+ from agents.analysis.backend.app.main import app as analysis_app
176
+ from agents.anomaly.backend.api_chat import app as anomaly_app
177
+ from agents.telemetry.main import app as telemetry_app
178
+ from agents.report.main import app as report_app
179
+
180
+ # Mount sub-agents natively on their respective sub-paths
181
+ app.mount("/live", live_app)
182
+ app.mount("/analysis", analysis_app)
183
+ app.mount("/anomaly", anomaly_app)
184
+ app.mount("/telemetry", telemetry_app)
185
+ app.mount("/report", report_app)
186
+
187
+ # Custom route to force download report files or preview in browser
188
+ @app.get("/reports/{filename}")
189
+ async def download_report(filename: str):
190
+
191
+ filepath = os.path.join("scratch/reports", filename)
192
+ if not os.path.exists(filepath):
193
+ raise HTTPException(status_code=404, detail="File not found")
194
+
195
+ if filename.endswith(".pdf"):
196
+ # Serve PDF inline for browser previewing in a new tab
197
+ return FileResponse(
198
+ path=filepath,
199
+ media_type="application/pdf",
200
+ headers={"Content-Disposition": f"inline; filename={filename}"}
201
+ )
202
+ else:
203
+ # Serve spreadsheet as a forced direct download
204
+ media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" if filename.endswith(".xlsx") else "application/octet-stream"
205
+ return FileResponse(
206
+ path=filepath,
207
+ filename=filename,
208
+ media_type=media_type
209
+ )
210
+
211
+ # Serve report files statically (fallback)
212
+ os.makedirs("scratch/reports", exist_ok=True)
213
+ app.mount("/reports", StaticFiles(directory="scratch/reports"), name="reports")
214
+
215
+
216
+ DEFAULT_USER_ID = os.getenv("DEFAULT_USER_ID", "ad7103e4-9595-4000-a56a-8f650e3eddef")
217
+
218
+ app.add_middleware(
219
+ CORSMiddleware,
220
+ allow_origins=["*"],
221
+ allow_credentials=True,
222
+ allow_methods=["*"],
223
+ allow_headers=["*"],
224
+ )
225
+
226
+ @app.get("/", tags=["Root"])
227
+ async def read_index():
228
+ return {
229
+ "status": "online",
230
+ "service": "Multi-Agent Telemetry SuperAgent API",
231
+ "integrations": {
232
+ "live_agent": "Connected [OK]",
233
+ "analysis_agent": "Connected [OK]",
234
+ "anomaly_agent": "Connected [OK]",
235
+ "postgresql_db": "Connected [OK]"
236
+ },
237
+ "api_endpoints_guide": {
238
+ "/health": "GET - Check server status.",
239
+ "/api/v1/chat/message": "POST - Unified AI chat endpoint (Live/Analysis/Anomaly).",
240
+ "/api/v1/assets": "GET - List authorized assets for a user.",
241
+ "/api/v1/schedules": "GET - List available automated report types.",
242
+ "/api/v1/schedules/run/{id}": "POST - Manually trigger a specific report.",
243
+ "/api/v1/anomalies": "GET - Real-time summary of all active incidents.",
244
+ "/docs": "GET - Interactive Swagger UI documentation."
245
+ },
246
+ "message": "Backend is running independently. Connect your frontend to this URL."
247
+ }
248
+
249
+ @app.get("/health")
250
+ def health() -> dict[str, str]:
251
+ return {"status": "ok"}
252
+
253
+ @app.get("/api/v1/assets")
254
+ async def list_assets(user_id: str = None):
255
+ from app.services.asset_registry import asset_registry
256
+ assets = asset_registry.list_all_assets(user_id=user_id or DEFAULT_USER_ID)
257
+ return assets
258
+
259
+ @app.get("/api/v1/assets/{asset_id}")
260
+ async def get_asset(asset_id: str, user_id: str = None):
261
+ from app.services.asset_registry import asset_registry
262
+ asset = asset_registry.get_asset_by_id(asset_id, user_id=user_id or DEFAULT_USER_ID)
263
+ if not asset:
264
+ raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found or not authorized.")
265
+ return asset
266
+
267
+ @app.get("/debug/logs")
268
+ async def get_logs():
269
+ logs = {"cwd": os.getcwd()}
270
+ for agent in ["startup", "live_agent", "analysis_agent", "anomaly_agent"]:
271
+ log_path = f"/app/{agent}.log"
272
+ if os.path.exists(log_path):
273
+ with open(log_path, "r") as f:
274
+ logs[agent] = f.read()[-2000:] # Last 2000 chars
275
+ else:
276
+ # Fallback to relative path if /app/ doesn't exist
277
+ rel_path = f"../{agent}.log"
278
+ if os.path.exists(rel_path):
279
+ with open(rel_path, "r") as f:
280
+ logs[agent] = f.read()[-2000:]
281
+ else:
282
+ logs[agent] = f"Log file not found at {log_path} or {rel_path}"
283
+ return logs
284
+
285
+ def maybe_auto_title_session(session_id: str, first_query: str):
286
+ import os
287
+ import re
288
+ from sqlalchemy import text
289
+ from app.services.asset_registry import asset_registry
290
+
291
+ check_sql = "SELECT title FROM chat_sessions WHERE id = :id"
292
+ try:
293
+ with SupabaseSessionLocal() as session:
294
+ title = session.execute(text(check_sql), {"id": session_id}).scalar()
295
+ if not title or title != "New Chat":
296
+ return
297
+
298
+ api_key = os.getenv("GROQ_API_KEY")
299
+ model = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
300
+ if not api_key:
301
+ print("AUTO-TITLE: No Groq API Key found. Skipping auto-title.")
302
+ return
303
+
304
+ from groq import Groq
305
+ client = Groq(api_key=api_key)
306
+
307
+ prompt = f"""
308
+ Create a short, professional topic title of 3-5 words summarizing the user's query.
309
+ Focus strictly on the core asset (e.g., Coldroom 1, Tank 3) or metric (e.g., Temperature, Humidity) mentioned.
310
+ Do NOT output generic diagnostic messages like "Unknown or Incomplete Query" or "Vague Query".
311
+ If the query is too short or hard to classify, summarize the query text directly (e.g., "Query about {first_query}").
312
+ Do not add extra symbols, markdown, or quotes.
313
+ Query: "{first_query}"
314
+ Title:
315
+ """
316
+
317
+ response = client.chat.completions.create(
318
+ messages=[
319
+ {"role": "user", "content": prompt}
320
+ ],
321
+ model=model,
322
+ temperature=0.3,
323
+ max_tokens=15,
324
+ )
325
+
326
+ new_title = response.choices[0].message.content.strip()
327
+ new_title = re.sub(r'^["\']|["\']$', '', new_title).strip()
328
+
329
+ if len(new_title) > 60:
330
+ new_title = new_title[:57] + "..."
331
+
332
+ if new_title:
333
+ update_sql = "UPDATE chat_sessions SET title = :title WHERE id = :id"
334
+ session.execute(text(update_sql), {"title": new_title, "id": session_id})
335
+ session.commit()
336
+ print(f"AUTO-TITLE: Updated session {session_id} title to: '{new_title}'")
337
+ except Exception as e:
338
+ print(f"AUTO-TITLE ERROR: {e}")
339
+
340
+ @app.post("/api/v1/chat/message", response_model=ChatResponse)
341
+ async def chat_message(payload: ChatRequest, background_tasks: BackgroundTasks) -> ChatResponse:
342
+ print(f"DEBUG: Received request: {payload}")
343
+ try:
344
+ response = await router_service.route_request(payload)
345
+
346
+ session_id = payload.session_id
347
+ if session_id and session_id != "default":
348
+ parsed_uid, clean_message = router_service._parse_user_message(payload.message)
349
+ effective_user_id = payload.user_id if (payload.user_id and payload.user_id != "user_1") else DEFAULT_USER_ID
350
+ if parsed_uid and parsed_uid != "user_1":
351
+ effective_user_id = parsed_uid
352
+
353
+ ensure_session_sql = """
354
+ INSERT INTO chat_sessions (id, user_id, title, created_at, updated_at)
355
+ VALUES (:id, :user_id, :title, NOW(), NOW())
356
+ ON CONFLICT (id) DO UPDATE SET updated_at = NOW();
357
+ """
358
+
359
+ insert_message_sql = """
360
+ INSERT INTO chat_messages (session_id, user_id, role, content, created_at)
361
+ VALUES (:session_id, :user_id, :role, :content, NOW())
362
+ """
363
+
364
+ try:
365
+ with SupabaseSessionLocal() as db_session:
366
+ db_session.execute(text(ensure_session_sql), {
367
+ "id": session_id,
368
+ "user_id": effective_user_id,
369
+ "title": "New Chat"
370
+ })
371
+
372
+ db_session.execute(text(insert_message_sql), {
373
+ "session_id": session_id,
374
+ "user_id": effective_user_id,
375
+ "role": "user",
376
+ "content": clean_message
377
+ })
378
+
379
+ db_session.execute(text(insert_message_sql), {
380
+ "session_id": session_id,
381
+ "user_id": effective_user_id,
382
+ "role": "assistant",
383
+ "content": response.answer
384
+ })
385
+ db_session.commit()
386
+
387
+ background_tasks.add_task(maybe_auto_title_session, session_id, clean_message)
388
+ except Exception as e:
389
+ print(f"CHAT HISTORY LOGGING ERROR: {e}")
390
+
391
+ return response
392
+ except Exception as exc:
393
+ import traceback
394
+ error_msg = f"{type(exc).__name__}: {str(exc)}"
395
+ tb = traceback.format_exc()
396
+ print(f"ERROR: {error_msg}\n{tb}")
397
+ raise HTTPException(status_code=500, detail=f"Internal error: {error_msg}\n{tb[:200]}") from exc
398
+
399
+ @app.get("/api/v1/chat/sessions")
400
+ async def list_chat_sessions(user_id: Optional[str] = None):
401
+ effective_user_id = user_id if (user_id and user_id != "user_1") else DEFAULT_USER_ID
402
+ query = """
403
+ SELECT id, title, created_at, updated_at
404
+ FROM chat_sessions
405
+ WHERE user_id = :user_id
406
+ ORDER BY updated_at DESC
407
+ """
408
+ from sqlalchemy import text
409
+ try:
410
+ with SupabaseSessionLocal() as session:
411
+ rows = session.execute(text(query), {"user_id": effective_user_id}).mappings().all()
412
+ return [dict(row) for row in rows]
413
+ except Exception as e:
414
+ raise HTTPException(status_code=500, detail=f"Failed to fetch chat sessions: {str(e)}")
415
+
416
+ @app.post("/api/v1/chat/sessions")
417
+ async def create_chat_session(payload: SessionCreateRequest):
418
+ import uuid
419
+ from sqlalchemy import text
420
+ from app.services.asset_registry import asset_registry
421
+
422
+ user_id = payload.user_id if (payload.user_id and payload.user_id != "user_1") else DEFAULT_USER_ID
423
+ session_id = f"session_{uuid.uuid4().hex[:12]}"
424
+ title = "New Chat"
425
+
426
+ query = """
427
+ INSERT INTO chat_sessions (id, user_id, title, created_at, updated_at)
428
+ VALUES (:id, :user_id, :title, NOW(), NOW())
429
+ RETURNING id, title, created_at, updated_at
430
+ """
431
+ try:
432
+ with SupabaseSessionLocal() as session:
433
+ row = session.execute(text(query), {
434
+ "id": session_id,
435
+ "user_id": user_id,
436
+ "title": title
437
+ }).mappings().first()
438
+ session.commit()
439
+ return dict(row)
440
+ except Exception as e:
441
+ raise HTTPException(status_code=500, detail=f"Failed to create chat session: {str(e)}")
442
+
443
+ @app.get("/api/v1/chat/sessions/{session_id}/messages")
444
+ async def get_session_messages(session_id: str):
445
+ from sqlalchemy import text
446
+ from app.services.asset_registry import asset_registry
447
+
448
+ query = """
449
+ SELECT role, content
450
+ FROM chat_messages
451
+ WHERE session_id = :session_id
452
+ ORDER BY created_at ASC, id ASC
453
+ """
454
+ try:
455
+ with SupabaseSessionLocal() as session:
456
+ rows = session.execute(text(query), {"session_id": session_id}).mappings().all()
457
+ return [dict(row) for row in rows]
458
+ except Exception as e:
459
+ raise HTTPException(status_code=500, detail=f"Failed to fetch messages for session: {str(e)}")
460
+
461
+ @app.put("/api/v1/chat/sessions/{session_id}")
462
+ async def update_session_title(session_id: str, payload: SessionTitleUpdateRequest):
463
+ from sqlalchemy import text
464
+ from app.services.asset_registry import asset_registry
465
+
466
+ query = """
467
+ UPDATE chat_sessions
468
+ SET title = :title, updated_at = NOW()
469
+ WHERE id = :session_id
470
+ """
471
+ try:
472
+ with SupabaseSessionLocal() as session:
473
+ res = session.execute(text(query), {"title": payload.title, "session_id": session_id})
474
+ session.commit()
475
+ if res.rowcount == 0:
476
+ raise HTTPException(status_code=404, detail="Session not found")
477
+ return {"success": True}
478
+ except HTTPException:
479
+ raise
480
+ except Exception as e:
481
+ raise HTTPException(status_code=500, detail=f"Failed to update session title: {str(e)}")
482
+
483
+ @app.delete("/api/v1/chat/sessions/{session_id}")
484
+ async def delete_chat_session(session_id: str):
485
+ from sqlalchemy import text
486
+ from app.services.asset_registry import asset_registry
487
+
488
+ delete_messages_sql = "DELETE FROM chat_messages WHERE session_id = :session_id"
489
+ delete_session_sql = "DELETE FROM chat_sessions WHERE id = :session_id"
490
+ try:
491
+ with SupabaseSessionLocal() as session:
492
+ check_res = session.execute(text("SELECT 1 FROM chat_sessions WHERE id = :session_id"), {"session_id": session_id}).scalar()
493
+ if not check_res:
494
+ raise HTTPException(status_code=404, detail="Session not found")
495
+
496
+ session.execute(text(delete_messages_sql), {"session_id": session_id})
497
+ session.execute(text(delete_session_sql), {"session_id": session_id})
498
+ session.commit()
499
+ return {"success": True}
500
+ except HTTPException:
501
+ raise
502
+ except Exception as e:
503
+ raise HTTPException(status_code=500, detail=f"Failed to delete chat session: {str(e)}")
504
+
505
+
506
+
507
+ @app.get("/api/v1/anomalies")
508
+ async def get_active_anomalies(user_id: str = None, schema_name: str = None):
509
+ # Centralized schema verification for anomalies endpoint
510
+ resolved_schema = "openremote"
511
+ if schema_name and schema_name.lower() != "openremote":
512
+ try:
513
+ from app.services.asset_registry import asset_registry
514
+ from sqlalchemy import text
515
+ with asset_registry.SessionLocal() as session:
516
+ res = session.execute(text(
517
+ "SELECT 1 FROM information_schema.tables WHERE table_schema = :schema AND table_name = 'asset' LIMIT 1"
518
+ ), {"schema": schema_name})
519
+ if res.scalar() is not None:
520
+ resolved_schema = schema_name
521
+ except Exception:
522
+ resolved_schema = "openremote"
523
+
524
+ port = os.getenv("PORT", "8000")
525
+ anomaly_agent_url = os.getenv("ANOMALY_AGENT_URL", f"http://127.0.0.1:{port}/anomaly")
526
+ if "localhost" in anomaly_agent_url or "127.0.0.1" in anomaly_agent_url:
527
+ anomaly_agent_url = re.sub(r":\d+", f":{port}", anomaly_agent_url)
528
+ async with httpx.AsyncClient(timeout=300.0) as client:
529
+ response = await client.get(
530
+ f"{anomaly_agent_url}/api/v1/anomalies",
531
+ params={
532
+ "user_id": user_id or DEFAULT_USER_ID,
533
+ "schema_name": resolved_schema
534
+ }
535
+ )
536
+ return response.json()
537
+
538
+ @app.get("/api/v1/reports/daily")
539
+ async def get_daily_reports():
540
+ import csv
541
+ import ast
542
+ import sys
543
+ csv.field_size_limit(2147483647)
544
+ from pathlib import Path
545
+
546
+ reports = {"coldrooms": [], "tanks": [], "smoke_alarms": []}
547
+ base_dir = Path(__file__).parent.parent
548
+ cr_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "coldroom_reports.csv"
549
+ tank_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "tank_reports.csv"
550
+ smoke_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "smoke_reports.csv"
551
+
552
+ def parse_csv(file_path, target_type):
553
+ if not file_path.exists(): return []
554
+ with open(file_path, mode='r', encoding='utf-8') as f:
555
+ reader = csv.DictReader(f)
556
+ all_rows = list(reader)
557
+ if not all_rows: return []
558
+ filtered_rows = [row for row in all_rows if row.get('report_type') == target_type]
559
+ if not filtered_rows: return []
560
+ latest_time = max(row['report_timestamp'] for row in filtered_rows)
561
+ latest_rows = [row for row in filtered_rows if row['report_timestamp'] == latest_time]
562
+ for row in latest_rows:
563
+ if 'anomalies' in row:
564
+ try: row['anomalies'] = ast.literal_eval(row['anomalies'])
565
+ except: pass
566
+ return latest_rows
567
+
568
+ reports["coldrooms"] = parse_csv(cr_path, "daily")
569
+ reports["tanks"] = parse_csv(tank_path, "daily")
570
+ reports["smoke_alarms"] = parse_csv(smoke_path, "daily")
571
+ return reports
572
+
573
+ @app.get("/api/v1/reports/latest")
574
+ async def get_latest_rolling_reports():
575
+ import csv
576
+ import ast
577
+ import sys
578
+ csv.field_size_limit(2147483647)
579
+ from pathlib import Path
580
+
581
+ reports = {"coldrooms": [], "tanks": [], "smoke_alarms": []}
582
+ base_dir = Path(__file__).parent.parent
583
+ cr_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "coldroom_reports.csv"
584
+ tank_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "tank_reports.csv"
585
+ smoke_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "smoke_reports.csv"
586
+
587
+ def parse_csv(file_path, target_type):
588
+ if not file_path.exists(): return []
589
+ with open(file_path, mode='r', encoding='utf-8') as f:
590
+ reader = csv.DictReader(f)
591
+ all_rows = list(reader)
592
+ if not all_rows: return []
593
+ filtered_rows = [row for row in all_rows if row.get('report_type') == target_type]
594
+ if not filtered_rows: return []
595
+ latest_time = max(row['report_timestamp'] for row in filtered_rows)
596
+ latest_rows = [row for row in filtered_rows if row['report_timestamp'] == latest_time]
597
+ for row in latest_rows:
598
+ if 'anomalies' in row:
599
+ try: row['anomalies'] = ast.literal_eval(row['anomalies'])
600
+ except: pass
601
+ return latest_rows
602
+
603
+ reports["coldrooms"] = parse_csv(cr_path, "rolling")
604
+ reports["tanks"] = parse_csv(tank_path, "rolling")
605
+ reports["smoke_alarms"] = parse_csv(smoke_path, "rolling")
606
+ return reports
607
+
608
+ import uuid
609
+ import json
610
+ import datetime
611
+ import time
612
+ import threading
613
+ from pydantic import BaseModel
614
+ from typing import Optional
615
+
616
+ class ManualReportRequest(BaseModel):
617
+ user_id: Optional[str] = None
618
+ asset_id: str
619
+ timeframe: str
620
+ email: Optional[str] = None
621
+ format: Optional[str] = "pdf"
622
+ report_type: Optional[str] = "analysis"
623
+
624
+ class ScheduleSaveRequest(BaseModel):
625
+ user_id: Optional[str] = None
626
+ asset_id: str
627
+ frequency: str
628
+ schedule_time: str
629
+ weekly_day: Optional[str] = None
630
+ recipient_email: str
631
+ report_start_hour: int
632
+ report_end_hour: int
633
+ report_format: str = "pdf" # 'pdf' or 'excel'
634
+ report_type: Optional[str] = "analysis" # 'analysis', 'trend', or 'anomaly'
635
+ is_active: bool = True
636
+
637
+ def get_supabase_client():
638
+ supabase_url = os.getenv("SUPABASE_URL")
639
+ supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
640
+ if not supabase_url or not supabase_key:
641
+ raise ValueError("SUPABASE_URL or SUPABASE_SERVICE_KEY environment variables are not set")
642
+ from supabase import create_client
643
+ return create_client(supabase_url, supabase_key)
644
+
645
+ def trigger_automated_report(user_id: str, asset_id: str, recipient_email: str, start_hour: int, end_hour: int, report_format: str, report_type: str = "analysis"):
646
+ try:
647
+ from app.services.asset_registry import asset_registry
648
+ from agents.report.app.services.report_generator import report_generator_service
649
+ from app.services.email_service import email_service
650
+
651
+ # 1. Resolve asset
652
+ asset = asset_registry.get_asset_by_id(asset_id, user_id=user_id)
653
+ asset_name = asset["name"] if asset else asset_id
654
+
655
+ # Build specific custom query suffix representing hours range
656
+ today_date = datetime.datetime.now().strftime("%Y-%m-%d")
657
+ custom_query = f"{asset_name} from {today_date} {start_hour:02d}:00:00 to {today_date} {end_hour:02d}:00:00"
658
+
659
+ print(f"SCHEDULER: Generating custom timeframe report ({report_format}) for {asset_name}: '{custom_query}'...")
660
+ res = report_generator_service.generate_report(
661
+ user_id=user_id,
662
+ asset_query=custom_query,
663
+ format_type=report_format,
664
+ asset_id=asset_id,
665
+ report_type=report_type
666
+ )
667
+
668
+ if res.get("success") and recipient_email:
669
+ filepath = res.get("filepath")
670
+ subject = f"Scheduled Operational Report: {asset_name}"
671
+ body_html = f"""
672
+ <html>
673
+ <body>
674
+ <h2 style="color: #1A365D;">Scheduled Operational Report</h2>
675
+ <p>Attached is the custom timeframe report (<b>{report_format.upper()}</b>) for <b>{asset_name}</b>.</p>
676
+ <p><b>Data Timeframe:</b> {start_hour:02d}:00 to {end_hour:02d}:00 (Local Time)</p>
677
+ </body>
678
+ </html>
679
+ """
680
+ email_service.send_report(
681
+ recipient=recipient_email,
682
+ subject=subject,
683
+ body_html=body_html,
684
+ attachment_path=filepath
685
+ )
686
+ print(f"SCHEDULER: Automated report sent to {recipient_email}")
687
+ else:
688
+ print(f"SCHEDULER: Report generation failed or no email configured. Success: {res.get('success')}")
689
+ except Exception as e:
690
+ print(f"SCHEDULER ERROR: Failed to run automated report: {e}")
691
+
692
+ def run_background_scheduler():
693
+ print("Background report scheduler loop running (Supabase integration)...")
694
+
695
+ # 1. Run an immediate check on server startup to verify database queries and process pending reports
696
+ try:
697
+ print("SCHEDULER: Running initial database schedule check on server startup...")
698
+ supabase = get_supabase_client()
699
+ response = supabase.table("report_schedule").select("*").eq("is_active", True).execute()
700
+ schedules = response.data or []
701
+
702
+ now = datetime.datetime.now()
703
+ today_str = now.strftime("%Y-%m-%d")
704
+ current_time_str = now.strftime("%H:%M")
705
+ current_day_name = now.strftime("%A")
706
+
707
+ print(f"SCHEDULER: Found {len(schedules)} active schedules. Current local server time: {current_time_str} ({current_day_name})")
708
+
709
+ for s in schedules:
710
+ frequency = s.get("frequency", "daily").lower()
711
+ schedule_time = s.get("schedule_time", "09:00")
712
+ weekly_day = s.get("weekly_day")
713
+ last_run = s.get("last_run")
714
+
715
+ should_run = False
716
+ # Check if current time is past the schedule run time
717
+ if current_time_str >= schedule_time:
718
+ if frequency == "daily":
719
+ if last_run != today_str:
720
+ should_run = True
721
+ elif frequency == "weekly":
722
+ if weekly_day and weekly_day.lower() == current_day_name.lower():
723
+ if last_run != today_str:
724
+ should_run = True
725
+
726
+ print(f"SCHEDULER STARTUP CHECK -> Asset ID: {s.get('asset_id')}, Time: {schedule_time}, Frequency: {frequency}, Last Run: {last_run}, Match: {should_run}")
727
+
728
+ if should_run:
729
+ asset_id = s.get("asset_id")
730
+ user_id = s.get("user_id") or "ad7103e4-9595-4000-a56a-8f650e3eddef"
731
+ recipient_email = s.get("recipient_email")
732
+ start_hour = s.get("report_start_hour", 9)
733
+ end_hour = s.get("report_end_hour", 15)
734
+ report_format = s.get("report_format", "pdf")
735
+ report_type = s.get("report_type", "analysis")
736
+
737
+ print(f"SCHEDULER: Launching automated report thread for {recipient_email}...")
738
+ threading.Thread(
739
+ target=trigger_automated_report,
740
+ args=(user_id, asset_id, recipient_email, start_hour, end_hour, report_format, report_type)
741
+ ).start()
742
+
743
+ # Log execution to Supabase to prevent duplicate execution today
744
+ supabase.table("report_schedule").update({"last_run": today_str}).eq("id", s["id"]).execute()
745
+ except Exception as startup_err:
746
+ print(f"SCHEDULER STARTUP ERROR: Failed to run initial startup check: {startup_err}")
747
+
748
+ # 2. Main infinite polling loop (sleep first, then check)
749
+ while True:
750
+ try:
751
+ # Sleep for 30 minutes (1800 seconds) between database checks
752
+ time.sleep(1800)
753
+
754
+ print("SCHEDULER: Performing periodic database schedule check...")
755
+ supabase = get_supabase_client()
756
+
757
+ # Fetch active schedules from Supabase report_schedule table
758
+ response = supabase.table("report_schedule").select("*").eq("is_active", True).execute()
759
+ schedules = response.data or []
760
+
761
+ now = datetime.datetime.now()
762
+ today_str = now.strftime("%Y-%m-%d")
763
+ current_time_str = now.strftime("%H:%M")
764
+ current_day_name = now.strftime("%A")
765
+
766
+ print(f"SCHEDULER: Periodic check running. Current local server time: {current_time_str}")
767
+
768
+ for s in schedules:
769
+ frequency = s.get("frequency", "daily").lower()
770
+ schedule_time = s.get("schedule_time", "09:00")
771
+ weekly_day = s.get("weekly_day")
772
+ last_run = s.get("last_run")
773
+
774
+ should_run = False
775
+ # If current time is equal to or past the scheduled time (HH:MM)
776
+ if current_time_str >= schedule_time:
777
+ if frequency == "daily":
778
+ if last_run != today_str:
779
+ should_run = True
780
+ elif frequency == "weekly":
781
+ if weekly_day and weekly_day.lower() == current_day_name.lower():
782
+ if last_run != today_str:
783
+ should_run = True
784
+
785
+ print(f"SCHEDULER PERIODIC CHECK -> Asset ID: {s.get('asset_id')}, Time: {schedule_time}, Match: {should_run}")
786
+
787
+ if should_run:
788
+ asset_id = s.get("asset_id")
789
+ user_id = s.get("user_id") or "ad7103e4-9595-4000-a56a-8f650e3eddef"
790
+ recipient_email = s.get("recipient_email")
791
+ start_hour = s.get("report_start_hour", 9)
792
+ end_hour = s.get("report_end_hour", 15)
793
+ report_format = s.get("report_format", "pdf")
794
+ report_type = s.get("report_type", "analysis")
795
+
796
+ print(f"SCHEDULER: Launching automated report thread for {recipient_email}...")
797
+ threading.Thread(
798
+ target=trigger_automated_report,
799
+ args=(user_id, asset_id, recipient_email, start_hour, end_hour, report_format, report_type)
800
+ ).start()
801
+
802
+ # Log execution date to Supabase to prevent duplicate execution today
803
+ supabase.table("report_schedule").update({"last_run": today_str}).eq("id", s["id"]).execute()
804
+
805
+ except Exception as e:
806
+ print(f"Error in background scheduler loop: {e}")
807
+
808
+ def init_chat_history_tables():
809
+ from sqlalchemy import text
810
+ from app.services.asset_registry import asset_registry
811
+
812
+ # 1. Create chat_sessions metadata table
813
+ create_sessions_sql = """
814
+ CREATE TABLE IF NOT EXISTS chat_sessions (
815
+ id TEXT PRIMARY KEY,
816
+ user_id TEXT NOT NULL,
817
+ title TEXT NOT NULL,
818
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
819
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
820
+ );
821
+ """
822
+
823
+ # 2. Create index on user_id
824
+ create_index_sql = """
825
+ CREATE INDEX IF NOT EXISTS idx_chat_sessions_user ON chat_sessions(user_id);
826
+ """
827
+
828
+ # 3. Create chat_messages table if not exists (fallback)
829
+ create_messages_sql = """
830
+ CREATE TABLE IF NOT EXISTS chat_messages (
831
+ id BIGSERIAL PRIMARY KEY,
832
+ session_id TEXT NOT NULL,
833
+ role TEXT NOT NULL,
834
+ content TEXT NOT NULL,
835
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
836
+ );
837
+ """
838
+
839
+ # 4. Alter chat_messages to add user_id if not present
840
+ alter_messages_sql = """
841
+ ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS user_id TEXT;
842
+ """
843
+
844
+ try:
845
+ with SupabaseEngine.begin() as conn:
846
+ conn.execute(text(create_sessions_sql))
847
+ conn.execute(text(create_index_sql))
848
+ conn.execute(text(create_messages_sql))
849
+ conn.execute(text(alter_messages_sql))
850
+ print("CHAT HISTORY SCHEMA: Initialized successfully.")
851
+ except Exception as e:
852
+ print(f"CHAT HISTORY SCHEMA ERROR: Failed to initialize: {e}")
853
+
854
+ @app.on_event("startup")
855
+ async def start_scheduler_on_startup():
856
+ # Initialize chat history database tables
857
+ init_chat_history_tables()
858
+
859
+ try:
860
+ # Start scheduler loop if Supabase keys exist
861
+ get_supabase_client()
862
+ threading.Thread(target=run_background_scheduler, daemon=True).start()
863
+ except Exception as e:
864
+ print(f"SCHEDULER NOT STARTED: {e}")
865
+
866
+ @app.post("/api/v1/analysis/schedule/manual")
867
+ async def manual_report_endpoint(payload: ManualReportRequest):
868
+ try:
869
+ from app.services.asset_registry import asset_registry
870
+ from agents.report.app.services.report_generator import report_generator_service
871
+ from app.services.email_service import email_service
872
+
873
+ user_id = payload.user_id or DEFAULT_USER_ID
874
+ asset = asset_registry.get_asset_by_id(payload.asset_id, user_id=user_id)
875
+ if not asset:
876
+ raise HTTPException(status_code=404, detail="Asset not found")
877
+
878
+ asset_name = asset["name"]
879
+ asset_query = f"{asset_name} {payload.timeframe}"
880
+
881
+ res = report_generator_service.generate_report(
882
+ user_id=user_id,
883
+ asset_query=asset_query,
884
+ format_type=payload.format or "pdf",
885
+ asset_id=payload.asset_id,
886
+ report_type=payload.report_type or "analysis"
887
+ )
888
+
889
+ if not res.get("success"):
890
+ raise HTTPException(status_code=400, detail=res.get("answer", "Failed to generate report"))
891
+
892
+ if payload.email:
893
+ filepath = res.get("filepath")
894
+ subject = f"Manual Telemetry Report: {asset_name}"
895
+ body_html = f"""
896
+ <html>
897
+ <body>
898
+ <h2 style="color: #1A365D;">Manual Telemetry Report</h2>
899
+ <p>Hello,</p>
900
+ <p>You requested a manual report for <b>{asset_name}</b>.</p>
901
+ <p><b>Timeframe:</b> {payload.timeframe.capitalize()}</p>
902
+ <hr/>
903
+ <p style="font-size: 11px; color: #718096;">Generated by Visbot Multi-Agent Gateway.</p>
904
+ </body>
905
+ </html>
906
+ """
907
+ email_service.send_report(
908
+ recipient=payload.email,
909
+ subject=subject,
910
+ body_html=body_html,
911
+ attachment_path=filepath
912
+ )
913
+
914
+ return {
915
+ "success": True,
916
+ "download_url": res.get("download_url"),
917
+ "filename": res.get("filename")
918
+ }
919
+ except Exception as e:
920
+ raise HTTPException(status_code=500, detail=str(e))
921
+
922
+ @app.post("/api/v1/analysis/schedule/save")
923
+ async def save_schedule_endpoint(payload: ScheduleSaveRequest):
924
+ try:
925
+ supabase = get_supabase_client()
926
+ user_id = payload.user_id or DEFAULT_USER_ID
927
+
928
+ # Check if record exists for this asset_id
929
+ check_resp = supabase.table("report_schedule").select("id").eq("asset_id", payload.asset_id).execute()
930
+
931
+ record_data = {
932
+ "user_id": user_id,
933
+ "asset_id": payload.asset_id,
934
+ "frequency": payload.frequency,
935
+ "schedule_time": payload.schedule_time,
936
+ "weekly_day": payload.weekly_day,
937
+ "recipient_email": payload.recipient_email,
938
+ "report_start_hour": payload.report_start_hour,
939
+ "report_end_hour": payload.report_end_hour,
940
+ "report_format": payload.report_format,
941
+ "report_type": payload.report_type,
942
+ "is_active": payload.is_active
943
+ }
944
+
945
+ if check_resp.data:
946
+ existing_id = check_resp.data[0]["id"]
947
+ supabase.table("report_schedule").update(record_data).eq("id", existing_id).execute()
948
+ else:
949
+ supabase.table("report_schedule").insert(record_data).execute()
950
+
951
+ return {"success": True, "message": "Schedule saved to Supabase successfully."}
952
+ except Exception as e:
953
+ import traceback
954
+ print(f"SAVE SCHEDULE ERROR: {e}\n{traceback.format_exc()}")
955
+ raise HTTPException(status_code=500, detail=str(e))
956
+
957
+ @app.get("/api/v1/analysis/schedule/{asset_id}")
958
+ async def get_schedule_endpoint(asset_id: str):
959
+ try:
960
+ supabase = get_supabase_client()
961
+ response = supabase.table("report_schedule").select("*").eq("asset_id", asset_id).execute()
962
+ if response.data:
963
+ return response.data[0]
964
+ return {"is_active": False}
965
+ except Exception as e:
966
+ raise HTTPException(status_code=500, detail=str(e))
967
+
968
+ if __name__ == "__main__":
969
+ import uvicorn
970
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
SuperAgent/scratch/debug_sql.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ WITH latest_attributes AS (
2
+ SELECT DISTINCT ON (attribute_name)
3
+ attribute_name,
4
+ value,
5
+ timestamp
6
+ FROM openremote.asset_datapoint
7
+ WHERE entity_id = (
8
+ SELECT id FROM openremote.asset
9
+ WHERE REPLACE(LOWER(name), ' ', '') = REPLACE(LOWER('Refinery'), ' ', '')
10
+ ORDER BY version DESC LIMIT 1
11
+ )
12
+ ORDER BY attribute_name, timestamp DESC
13
+ )
14
+ SELECT
15
+ (SELECT name FROM openremote.asset WHERE REPLACE(LOWER(name), ' ', '') = REPLACE(LOWER('Refinery'), ' ', '') ORDER BY version DESC LIMIT 1) AS asset_name,
16
+ MAX(timestamp) + INTERVAL '3 hours' AS local_time,
17
+ jsonb_object_agg(attribute_name, value) AS sensor_data
18
+ FROM latest_attributes
SuperAgent/scratch/raw_llm_output.txt ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Based on the system message and available context, we can determine that the user is asking for the current readings of the assets.
2
+
3
+ Since the user is asking for current readings, we can assume that the `asset_name` is not provided in the context. However, we can use the `allowed_assets` list to filter the assets.
4
+
5
+ We will use Scenario O: Custom Report / Multi-Asset to generate the SQL query.
6
+
7
+ Here is the plan:
8
+
9
+ 1. Filter the assets using the `allowed_assets` list.
10
+ 2. Use the filtered assets to generate the SQL query.
11
+ 3. Use the `ROW_NUMBER()` function to get the latest reading for each asset.
12
+ 4. Format the result as a Markdown table.
13
+
14
+ Here is the SQL query:
15
+ ```sql
16
+ WITH Latest AS (
17
+ SELECT a.name AS asset_name, ad.timestamp + INTERVAL '3 hours' AS local_time,
18
+ (ad.value::jsonb - 'Timestamp' - 'timestamp') AS sensor_data
19
+ FROM openremote.asset a
20
+ JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id
21
+ WHERE a.name IN (SELECT value FROM unnest(string_to_array(allowed_assets, ',')) AS value)
22
+ AND ad.attribute_name ILIKE 'data'
23
+ ORDER BY ad.timestamp DESC
24
+ LIMIT 1
25
+ )
26
+ SELECT asset_name, TO_CHAR(local_time, 'HH12:MI AM') AS time_label, sensor_data
27
+ FROM Latest;
28
+ ```
29
+ This query will return the current readings of the assets in the `allowed_assets` list.
30
+
31
+ Note: Since the `asset_name` is not provided in the context, we are using the `allowed_assets` list to filter the assets. If the `asset_name` is provided in the context, we can use it to filter the assets instead.
SuperAgent/scratch/read_pdf.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ pdf_path = r"d:\Visibility Bots Multi Agent\super-agent\SuperAgent\scratch\reports\report_coldroom1_20260520_172140.pdf"
5
+
6
+ if not os.path.exists(pdf_path):
7
+ print(f"Error: File not found at {pdf_path}")
8
+ sys.exit(1)
9
+
10
+ print(f"File size: {os.path.getsize(pdf_path)} bytes")
11
+
12
+ # Try to import typical PDF extraction libraries
13
+ try:
14
+ import pypdf
15
+ print("Using pypdf:")
16
+ reader = pypdf.PdfReader(pdf_path)
17
+ for i, page in enumerate(reader.pages):
18
+ print(f"--- Page {i+1} ---")
19
+ print(page.extract_text())
20
+ except ImportError:
21
+ try:
22
+ import PyPDF2
23
+ print("Using PyPDF2:")
24
+ reader = PyPDF2.PdfReader(pdf_path)
25
+ for i, page in enumerate(reader.pages):
26
+ print(f"--- Page {i+1} ---")
27
+ print(page.extract_text())
28
+ except ImportError:
29
+ try:
30
+ import pdfplumber
31
+ print("Using pdfplumber:")
32
+ with pdfplumber.open(pdf_path) as pdf:
33
+ for i, page in enumerate(pdf.pages):
34
+ print(f"--- Page {i+1} ---")
35
+ print(page.extract_text())
36
+ except ImportError:
37
+ print("No advanced PDF text extractor (pypdf, PyPDF2, pdfplumber) is installed.")
38
+ print("Reading raw PDF headers/metadata via basic binary inspection:")
39
+ with open(pdf_path, 'rb') as f:
40
+ head = f.read(1000)
41
+ print("Header:", head[:200])
42
+ f.seek(-1000, 2)
43
+ tail = f.read(1000)
44
+ print("Tail contains PDF EOF:", b"%%EOF" in tail)
SuperAgent/scratch/reports/report_1st_f_20260604_170502.pdf ADDED
Binary file (3.4 kB). View file
 
SuperAgent/scratch/reports/report_cold_room_01_20260603_101356.pdf ADDED
Binary file (11.1 kB). View file
 
SuperAgent/scratch/reports/report_cold_room_01_20260605_164130.pdf ADDED
Binary file (9.26 kB). View file
 
SuperAgent/scratch/reports/report_cold_room_02_20260601_111023.pdf ADDED
Binary file (3.73 kB). View file
 
SuperAgent/scratch/reports/report_cold_room_02_20260601_112724.pdf ADDED
Binary file (2.87 kB). View file
 
SuperAgent/scratch/reports/report_cold_room_03_20260601_110725.pdf ADDED
Binary file (2.82 kB). View file
 
SuperAgent/scratch/reports/report_coldroom10_20260520_151010.pdf ADDED
Binary file (4.75 kB). View file
 
SuperAgent/scratch/reports/report_coldroom10_20260520_151223.pdf ADDED
Binary file (4.75 kB). View file
 
SuperAgent/scratch/reports/report_coldroom10_20260520_154908.pdf ADDED
Binary file (4.76 kB). View file
 
SuperAgent/scratch/reports/report_coldroom10_20260520_155631.pdf ADDED
Binary file (4.72 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260520_161047.pdf ADDED
Binary file (2.86 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260520_161326.pdf ADDED
Binary file (2.86 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260520_162115.pdf ADDED
Binary file (4.73 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260520_164714.pdf ADDED
Binary file (2.94 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260520_171604.pdf ADDED
Binary file (22.8 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260520_172140.pdf ADDED
Binary file (10.1 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260520_172737.pdf ADDED
Binary file (2.77 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260521_114641.pdf ADDED
Binary file (2.38 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260601_104530.pdf ADDED
Binary file (6.1 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260601_105254.pdf ADDED
Binary file (4.3 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260609_151700.xlsx ADDED
Binary file (14.2 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260618_150027.pdf ADDED
Binary file (5.34 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260622_113931.pdf ADDED
Binary file (5.54 kB). View file
 
SuperAgent/scratch/reports/report_coldroom1_20260622_153634.pdf ADDED
Binary file (28.5 kB). View file
 
SuperAgent/scratch/reports/report_physical05_20260604_171330.pdf ADDED
Binary file (9.57 kB). View file
 
SuperAgent/scratch/reports/report_physical06_20260604_170048.pdf ADDED
Binary file (4.75 kB). View file
 
SuperAgent/scratch/reports/report_physical_03_20260603_101528.xlsx ADDED
Binary file (7.86 kB). View file
 
SuperAgent/scratch/reports/report_physical_refinery_20260604_171051.pdf ADDED
Binary file (4.76 kB). View file