zaheer140 commited on
Commit
c6eedad
·
verified ·
1 Parent(s): 5cc22d3

Upload 408 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 +19 -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__/report_generator.cpython-313.pyc +0 -0
  12. SuperAgent/app/services/__pycache__/router.cpython-312.pyc +0 -0
  13. SuperAgent/app/services/__pycache__/router.cpython-313.pyc +0 -0
  14. SuperAgent/app/services/asset_registry.py +324 -0
  15. SuperAgent/app/services/report_generator.py +13 -0
  16. SuperAgent/app/services/router.py +898 -0
  17. SuperAgent/main.py +282 -0
  18. SuperAgent/scratch/debug_sql.txt +14 -0
  19. SuperAgent/scratch/raw_llm_output.txt +65 -0
  20. SuperAgent/scratch/read_pdf.py +44 -0
  21. SuperAgent/scratch/reports/report_cold_room_02_20260601_111023.pdf +0 -0
  22. SuperAgent/scratch/reports/report_cold_room_02_20260601_112724.pdf +0 -0
  23. SuperAgent/scratch/reports/report_cold_room_03_20260601_110725.pdf +0 -0
  24. SuperAgent/scratch/reports/report_coldroom10_20260520_151010.pdf +0 -0
  25. SuperAgent/scratch/reports/report_coldroom10_20260520_151223.pdf +0 -0
  26. SuperAgent/scratch/reports/report_coldroom10_20260520_154908.pdf +0 -0
  27. SuperAgent/scratch/reports/report_coldroom10_20260520_155631.pdf +0 -0
  28. SuperAgent/scratch/reports/report_coldroom1_20260520_161047.pdf +0 -0
  29. SuperAgent/scratch/reports/report_coldroom1_20260520_161326.pdf +0 -0
  30. SuperAgent/scratch/reports/report_coldroom1_20260520_162115.pdf +0 -0
  31. SuperAgent/scratch/reports/report_coldroom1_20260520_164714.pdf +0 -0
  32. SuperAgent/scratch/reports/report_coldroom1_20260520_171604.pdf +0 -0
  33. SuperAgent/scratch/reports/report_coldroom1_20260520_172140.pdf +0 -0
  34. SuperAgent/scratch/reports/report_coldroom1_20260520_172737.pdf +0 -0
  35. SuperAgent/scratch/reports/report_coldroom1_20260521_114641.pdf +0 -0
  36. SuperAgent/scratch/reports/report_coldroom1_20260601_104530.pdf +0 -0
  37. SuperAgent/scratch/reports/report_coldroom1_20260601_105254.pdf +0 -0
  38. SuperAgent/scratch/reports/report_st1_20260521_141351.pdf +103 -0
  39. SuperAgent/scratch/reports/report_st1_20260521_141425.pdf +0 -0
  40. SuperAgent/scratch/reports/report_st1_20260521_142409.pdf +0 -0
  41. SuperAgent/scratch/reports/report_st1_20260521_143134.pdf +0 -0
  42. SuperAgent/scratch/reports/report_zone_1_20260601_100150.xlsx +0 -0
  43. agents/analysis/.DS_Store +0 -0
  44. agents/analysis/.env.example +7 -0
  45. agents/analysis/.gitignore +9 -0
  46. agents/analysis/backend/app/__init__.py +1 -0
  47. agents/analysis/backend/app/__pycache__/__init__.cpython-313.pyc +0 -0
  48. agents/analysis/backend/app/__pycache__/main.cpython-313.pyc +0 -0
  49. agents/analysis/backend/app/api/routes/__init__.py +1 -0
  50. agents/analysis/backend/app/api/routes/__pycache__/__init__.cpython-313.pyc +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 (17 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.33 kB). View file
 
SuperAgent/app/models/schemas.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ schema_name: Optional[str] = None
11
+
12
+
13
+ class ChatResponse(BaseModel):
14
+ answer: str
15
+ intent: Literal["live", "analysis", "anomaly", "report"]
16
+ analysis: Optional[Any] = None # Will hold AnalysisResponse if intent is analysis
17
+ data: Optional[Any] = None # Will hold rows if intent is live, or complex reports if anomaly
18
+ generated_sql: Optional[str] = None
19
+ orchestration: Optional[list[str]] = None
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 (17.2 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 (43.5 kB). View file
 
SuperAgent/app/services/asset_registry.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 get_summary(self, user_id: str) -> Dict[str, Any]:
75
+ """Returns a statistical summary of assets allowed for the user."""
76
+ self._load_user_assets(user_id)
77
+ assets = self._user_cache.get(user_id, [])
78
+
79
+ stats = {
80
+ "Total": len(assets),
81
+ "Tanks": 0,
82
+ "ColdRooms": 0,
83
+ "Machines": 0,
84
+ "Boilers": 0,
85
+ "SmokeAlarms": 0,
86
+ "Other": 0
87
+ }
88
+
89
+ for a in assets:
90
+ name = a['name'].lower()
91
+ if 'tank' in name or 'physical' in name: stats["Tanks"] += 1
92
+ elif 'cold' in name: stats["ColdRooms"] += 1
93
+ elif 'machine' in name or 'filling' in name: stats["Machines"] += 1
94
+ elif 'boiler' in name: stats["Boilers"] += 1
95
+ elif 'smoke' in name or 'alarm' in name: stats["SmokeAlarms"] += 1
96
+ else: stats["Other"] += 1
97
+
98
+ return stats
99
+
100
+ def _is_general_status_query(self, query: str) -> bool:
101
+ normalized = "".join(query.lower().split())
102
+ for kw in ["systemstatus", "statusofall", "allassets", "alldevices", "completestatus", "statusreport"]:
103
+ if kw in normalized:
104
+ return True
105
+ return False
106
+
107
+ def is_asset_mentioned(self, asset_name: str, query: str) -> bool:
108
+ """
109
+ Robust check if asset_name is mentioned in the query,
110
+ respecting word boundaries and avoiding substring overlap (e.g. "st1" inside "past10").
111
+ """
112
+ q = query.lower()
113
+ an = asset_name.lower()
114
+
115
+ # 1. Exact or direct substring with word boundaries in original query
116
+ words = re.findall(r'[a-zA-Z]+|\d+', an)
117
+ if not words:
118
+ return False
119
+
120
+ # Build a regex pattern that matches the words in order, allowing optional spaces/hyphens/underscores
121
+ pattern_parts = []
122
+ for i, w in enumerate(words):
123
+ pattern_parts.append(re.escape(w))
124
+ if i < len(words) - 1:
125
+ pattern_parts.append(r'\s*[-_]?\s*')
126
+
127
+ pattern = r'\b' + "".join(pattern_parts) + r'\b'
128
+
129
+ try:
130
+ if re.search(pattern, q):
131
+ return True
132
+ except Exception:
133
+ pass
134
+
135
+ # 2. Normalized fallback (only for names longer than 4 characters to avoid ST1/F1 false positives)
136
+ norm_q = self.normalize_name(q)
137
+ norm_an = self.normalize_name(an)
138
+ if len(norm_an) > 4 and norm_an in norm_q:
139
+ return True
140
+
141
+ return False
142
+
143
+ def find_mentioned_assets(self, query_name: str, user_id: str) -> List[Dict[str, Any]]:
144
+ self._load_user_assets(user_id)
145
+ allowed_assets = self._user_cache.get(user_id, [])
146
+
147
+ is_general_status = self._is_general_status_query(query_name)
148
+ mentioned = []
149
+
150
+ for a in allowed_assets:
151
+ if is_general_status and a['name'].lower() == 'status':
152
+ continue
153
+ if self.is_asset_mentioned(a['name'], query_name):
154
+ mentioned.append(a)
155
+
156
+ # If both a physical/non-Status asset and 'Status' are matched, discard 'Status'
157
+ if len(mentioned) >= 2:
158
+ has_physical = any(a['name'].lower() != 'status' for a in mentioned)
159
+ if has_physical:
160
+ mentioned = [a for a in mentioned if a['name'].lower() != 'status']
161
+
162
+ return mentioned
163
+
164
+ def find_asset(self, query_name: str, user_id: str) -> Optional[Dict[str, Any]]:
165
+ """Finds an asset by name using robust matching, filtered by user permissions."""
166
+ self._load_user_assets(user_id)
167
+ allowed_assets = self._user_cache.get(user_id, [])
168
+
169
+ # ---------------------------------------------------------------
170
+ # PRE-RESOLUTION: "Tank N" -> "physicalNN" (zero-padded, 2 digits)
171
+ # This mirrors the same rule in agents/live/system_message.md so
172
+ # that both the Live path and the Report path resolve to the same
173
+ # physical asset regardless of which path processes the message.
174
+ # ---------------------------------------------------------------
175
+ tank_alias_match = re.search(r'\btank\s*(\d+)\b', query_name, re.IGNORECASE)
176
+ if tank_alias_match:
177
+ tank_num = int(tank_alias_match.group(1))
178
+ physical_name = f'physical{tank_num:02d}' # e.g. physical11
179
+ for a in allowed_assets:
180
+ if self.normalize_name(a['name']) == physical_name:
181
+ logger.debug(f"Asset Registry: 'Tank {tank_num}' pre-resolved to '{a['name']}' ({a['id']})")
182
+ return a
183
+
184
+ normalized_query = self.normalize_name(query_name)
185
+ if not normalized_query: return None
186
+
187
+ is_general_status = self._is_general_status_query(query_name)
188
+
189
+ # 1. Exact normalized match (Highest Priority)
190
+ for a in allowed_assets:
191
+ if is_general_status and a['name'].lower() == 'status':
192
+ continue
193
+ if self.normalize_name(a['name']) == normalized_query:
194
+ return a
195
+
196
+ # 2. Category-based numbered matching (Priority 2 - prevents generic overrides)
197
+ 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))
198
+ is_cold = 'cold' in normalized_query
199
+ is_boiler = 'boiler' in normalized_query
200
+ is_machine = any(k in normalized_query for k in ['machine', 'filling'])
201
+
202
+ # Find a number that is NOT associated with time windows or limits (e.g., "10 minutes", "10 readings", "10 readngs" [typo])
203
+ num_matches = re.finditer(r'\b(\d+)\b', query_name)
204
+ target_num = None
205
+ for m in num_matches:
206
+ val = int(m.group(1))
207
+ start, end = m.span()
208
+ # Look at a small window around the match
209
+ surrounding = query_name[max(0, start - 15):min(len(query_name), end + 15)].lower()
210
+ if re.search(
211
+ r'\b\d+\s*(?:minute|min|hour|hr|day|week|month|read(?:ing|ngs?|ings?)|row|log|point|datapoint)s?\b',
212
+ surrounding
213
+ ):
214
+ continue
215
+ target_num = val
216
+ break
217
+
218
+ # Fallback to any first digit in the normalized query if no specific target is found
219
+ if target_num is None:
220
+ num_match = re.search(r'(\d+)', normalized_query)
221
+ target_num = int(num_match.group(1)) if num_match else None
222
+
223
+ if target_num is not None:
224
+ for a in allowed_assets:
225
+ if is_general_status and a['name'].lower() == 'status':
226
+ continue
227
+ db_name = a['name'].lower()
228
+ db_num_match = re.search(r'(\d+)', db_name)
229
+ if db_num_match and int(db_num_match.group(1)) == target_num:
230
+ cleaned_db = re.sub(r'[\s_-]', '', db_name)
231
+ is_db_tank = 'tank' in db_name or 'physical' in db_name or bool(re.match(r'^st\d+$', cleaned_db))
232
+ if is_tank and is_db_tank: return a
233
+ if is_cold and 'cold' in db_name: return a
234
+ if is_boiler and 'boiler' in db_name: return a
235
+ if is_machine and ('machine' in db_name or 'filling' in db_name): return a
236
+
237
+ # 3. Mentioned assets (Priority 3)
238
+ mentioned = self.find_mentioned_assets(query_name, user_id)
239
+
240
+ # Category constraint filtering: e.g. if query contains "tank", do not match asset "Oil"
241
+ filtered_mentioned = []
242
+ for a in mentioned:
243
+ db_name_lower = a['name'].lower()
244
+ cleaned_db = re.sub(r'[\s_-]', '', db_name_lower)
245
+ 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))
246
+ if is_tank and not is_db_tank:
247
+ continue
248
+ if is_cold and 'cold' not in db_name_lower:
249
+ continue
250
+ if is_boiler and 'boiler' not in db_name_lower:
251
+ continue
252
+ if is_machine and not any(k in db_name_lower for k in ['machine', 'filling']):
253
+ continue
254
+ filtered_mentioned.append(a)
255
+
256
+ if len(filtered_mentioned) == 1:
257
+ return filtered_mentioned[0]
258
+
259
+ if len(filtered_mentioned) >= 2:
260
+ # Multi-asset query! Check if they share a common parent
261
+ parent_ids = {a.get('parent_id') for a in filtered_mentioned if a.get('parent_id')}
262
+ if len(parent_ids) == 1:
263
+ parent_id = list(parent_ids)[0]
264
+ parent_asset = self.get_asset_by_id(parent_id, user_id)
265
+ if parent_asset:
266
+ logger.info(f"Asset Registry: Multi-asset comparison detected. Mapping to common parent '{parent_asset['name']}' ({parent_asset['id']})")
267
+ return parent_asset
268
+
269
+ # 4. Suffix-removed match (e.g. "Refinery Tank" -> "Refinery")
270
+ for suffix in ['tank', 'asset', 'sensor', 'machine', 'boiler', 'coldroom']:
271
+ if normalized_query.endswith(suffix):
272
+ short = normalized_query[:-len(suffix)]
273
+ for a in allowed_assets:
274
+ if is_general_status and a['name'].lower() == 'status':
275
+ continue
276
+ if self.normalize_name(a['name']) == short:
277
+ return a
278
+
279
+ # 5. Fallback specific keyword match
280
+ for a in allowed_assets:
281
+ if is_general_status and a['name'].lower() == 'status':
282
+ continue
283
+ db_name = a['name'].lower()
284
+ db_norm = self.normalize_name(db_name)
285
+ cleaned_db = re.sub(r'[\s_-]', '', db_name)
286
+ is_db_tank = 'tank' in db_name or 'physical' in db_name or bool(re.match(r'^st\d+$', cleaned_db))
287
+ if is_tank and is_db_tank:
288
+ if normalized_query in db_norm: return a
289
+ if is_cold and 'cold' in db_name:
290
+ if normalized_query in db_norm: return a
291
+ if is_boiler and 'boiler' in db_name:
292
+ if normalized_query in db_norm: return a
293
+ if is_machine and ('machine' in db_name or 'filling' in db_name):
294
+ if normalized_query in db_norm: return a
295
+
296
+ # 6. Last resort: Generic substring match
297
+ if len(normalized_query) > 3:
298
+ for a in allowed_assets:
299
+ if is_general_status and a['name'].lower() == 'status':
300
+ continue
301
+ db_norm = self.normalize_name(a['name'])
302
+ # Do not return extremely generic names like "Oil" as a fallback substring match if they are not specifically asked for
303
+ if db_norm in ['oil', 'water', 'status', 'temp', 'level']:
304
+ continue
305
+ if normalized_query in db_norm or db_norm in normalized_query:
306
+ return a
307
+
308
+ return None
309
+
310
+
311
+ return None
312
+
313
+ def get_asset_by_id(self, asset_id: str, user_id: str) -> Optional[Dict[str, Any]]:
314
+ self._load_user_assets(user_id)
315
+ for a in self._user_cache.get(user_id, []):
316
+ if a['id'] == asset_id:
317
+ return a
318
+ return None
319
+
320
+ def list_all_assets(self, user_id: str) -> List[Dict[str, Any]]:
321
+ self._load_user_assets(user_id)
322
+ return self._user_cache.get(user_id, [])
323
+
324
+ asset_registry = AssetRegistry()
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,898 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: Respect the explicit asset_id provided in the request payload
251
+ if state.get("asset_id"):
252
+ verified_asset = asset_registry.get_asset_by_id(state["asset_id"], state["user_id"])
253
+ if verified_asset:
254
+ asset_id = verified_asset['id']
255
+ asset_name = verified_asset['name']
256
+ else:
257
+ logger.warning(f"User {state['user_id']} attempted to access unauthorized asset {state['asset_id']}")
258
+
259
+ # 2. Second Priority: Fall back to fuzzy matching from the message ONLY if no explicit asset_id was provided
260
+ if not asset_id and not is_all_query:
261
+ asset = asset_registry.find_asset(state["message"], state["user_id"])
262
+ if asset:
263
+ asset_id = asset['id']
264
+ asset_name = asset['name']
265
+
266
+ orch = state.get("orchestration", [])
267
+ orch.append(f"Gateway: Intent classified as '{intent}'")
268
+
269
+ if is_all_query:
270
+ summary = asset_registry.get_summary(state["user_id"])
271
+ orch.append(f"Gateway: 'All Assets' query detected. System Summary: {summary}")
272
+ elif asset_id:
273
+ orch.append(f"Gateway: Identified asset '{asset_name}' ({asset_id})")
274
+
275
+ return {
276
+ "intent": intent or "live",
277
+ "asset_id": asset_id,
278
+ "asset_name": asset_name,
279
+ "orchestration": orch
280
+ }
281
+
282
+ def _route_from_intent(self, state: SuperAgentState) -> Intent:
283
+ return state.get("intent", "live")
284
+
285
+ async def _route_live_node(self, state: SuperAgentState) -> SuperAgentState:
286
+ orch = state.get("orchestration", [])
287
+ orch.append("Gateway -> Live Agent: Fetching real-time telemetry from database")
288
+ live_payload = {
289
+ "session_id": state.get("session_id") or "default",
290
+ "user_id": state.get("user_id"),
291
+ "message": state["message"],
292
+ "asset_id": state.get("asset_id"),
293
+ "asset_name": state.get("asset_name"),
294
+ "schema_name": state.get("schema_name"),
295
+ }
296
+ async with httpx.AsyncClient(timeout=300.0) as client:
297
+ response = await client.post(f"{self.telemetry_agent_url}/api/v1/chat/message", json=live_payload)
298
+ if response.status_code != 200:
299
+ orch.append("Live Agent: Failed to respond")
300
+ print(f"\n [GATEWAY ERROR] Live Route Failed: {response.text}\n")
301
+ return {
302
+ # "answer": f"Live Agent Error: {response.text}",
303
+ "answer": "The Gateway encountered an error communicating with the live telemetry service. Please try again.",
304
+ "intent": "live",
305
+ "orchestration": orch
306
+ }
307
+ data = response.json()
308
+ orch.append("Live Agent: Response received successfully")
309
+
310
+ return {
311
+ "answer": data.get("answer", ""),
312
+ "intent": "live",
313
+ "data": data.get("rows", []),
314
+ "generated_sql": data.get("generated_sql"),
315
+ "orchestration": orch
316
+ }
317
+
318
+ async def _route_analysis_node(self, state: SuperAgentState) -> SuperAgentState:
319
+ orch = state.get("orchestration", [])
320
+ orch.append("Gateway -> Analysis Agent: Initiating historical trend analysis")
321
+ analysis_payload = {
322
+ "message": state["message"],
323
+ "user_id": state.get("user_id"),
324
+ "asset_id": state.get("asset_id"),
325
+ "asset_name": state.get("asset_name"),
326
+ "schema_name": state.get("schema_name"),
327
+ }
328
+ async with httpx.AsyncClient(timeout=300.0) as client:
329
+ response = await client.post(
330
+ f"{self.telemetry_agent_url}/api/v1/chat/message",
331
+ json=analysis_payload,
332
+ )
333
+ if response.status_code != 200:
334
+ orch.append("Analysis Agent: Failed to respond")
335
+ print(f"\n [GATEWAY ERROR] Analysis Route Failed: {response.text}\n")
336
+ return {
337
+ # "answer": f"Analysis Agent Error: {response.text}",
338
+ "answer": "The Gateway encountered an error communicating with the analysis service. Please try again.",
339
+ "intent": "analysis",
340
+ "orchestration": orch
341
+ }
342
+ data = response.json()
343
+ orch.append("Analysis Agent: Trend analysis complete")
344
+
345
+ return {
346
+ "answer": data.get("answer", ""),
347
+ "intent": "analysis",
348
+ "analysis": data.get("analysis"),
349
+ "orchestration": orch
350
+ }
351
+
352
+ async def _route_anomaly_node(self, state: SuperAgentState) -> SuperAgentState:
353
+ orch = state.get("orchestration", [])
354
+ orch.append("Gateway -> Anomaly Agent (The Foreman): Activating multi-agent industrial supervisor")
355
+ anomaly_payload = {
356
+ "message": state["message"],
357
+ "user_id": state.get("user_id"),
358
+ "asset_id": state.get("asset_id"),
359
+ "asset_name": state.get("asset_name"),
360
+ "schema_name": state.get("schema_name"),
361
+ }
362
+ async with httpx.AsyncClient(timeout=600.0) as client:
363
+ response = await client.post(
364
+ f"{self.anomaly_agent_url}/api/chat",
365
+ json=anomaly_payload,
366
+ )
367
+ if response.status_code != 200:
368
+ orch.append("Anomaly Agent: Failed to respond")
369
+ return {
370
+ "answer": f"Anomaly Agent Error: {response.text}",
371
+ "intent": "anomaly",
372
+ "orchestration": orch
373
+ }
374
+ data = response.json()
375
+ orch.append("Anomaly Agent: Multi-agent reasoning cycle finished")
376
+
377
+ return {
378
+ "answer": data.get("reply", ""),
379
+ "intent": "anomaly",
380
+ "data": data.get("data"),
381
+ "orchestration": orch
382
+ }
383
+
384
+ def _classify_with_rules(self, message: str) -> Intent | None:
385
+ text = self._normalize_text(message)
386
+
387
+ # Check for report generation intent
388
+ report_keywords = [
389
+ "generate report", "download report", "get report", "create report",
390
+ "excel report", "pdf report", "xlsx report", "sheet report", "spreadsheet report",
391
+ "generate excel", "generate pdf", "download excel", "download pdf",
392
+ "save as pdf", "save as excel", "export to pdf", "export to excel",
393
+ "export pdf", "export excel", "make pdf", "make excel", "telemetry pdf", "telemetry excel",
394
+ "pdf file", "excel file", "xlsx file", "spreadsheet file", "sheet file",
395
+ "pdf", "excel", "xlsx", "spreadsheet", "csv"
396
+ ]
397
+ if any(kw in text for kw in report_keywords):
398
+ return "report"
399
+
400
+ # Route coldrooms/tanks/smoke alarm system status, what is happening,
401
+ # and how they are doing to "anomaly" so that the anomaly agent is run.
402
+ has_specific_asset = any(term in text for term in [
403
+ "coldroom", "cold room", "coldrooms", "cold rooms",
404
+ "tank", "tanks",
405
+ "smoke alarm", "smoke alarms", "smoke detector", "smoke detectors",
406
+ "smoke sensor", "smoke sensors", "fire alarm", "fire alarms"
407
+ ])
408
+ if has_specific_asset:
409
+ is_status_query = "status" in text or "state" in text
410
+ is_happening_query = "happening" in text or "going on" in text or "what is with" in text
411
+ is_doing_query = (
412
+ "doing" in text or
413
+ "how is" in text or
414
+ "how are" in text or
415
+ "how's" in text or
416
+ "is ok" in text or
417
+ "is okay" in text or
418
+ "are ok" in text or
419
+ "are okay" in text
420
+ )
421
+ if is_status_query or is_happening_query or is_doing_query:
422
+ return "anomaly"
423
+
424
+ # Force "live" routing for global asset status summaries
425
+ global_status_keywords = ["systemstatus", "statusofall", "allassetsstatus", "alldevicesstatus", "completestatus", "statusreport"]
426
+ if any(kw in text.replace(" ", "") for kw in global_status_keywords):
427
+ return "live"
428
+
429
+ analysis_phrases = (
430
+ "analysis",
431
+ "analyze",
432
+ "analyse",
433
+ "trend",
434
+ "history",
435
+ "historical",
436
+ "report",
437
+ "graph",
438
+ "chart",
439
+ "compare",
440
+ "comparison",
441
+ "average",
442
+ "avg",
443
+ "minimum",
444
+ "maximum",
445
+ "variance",
446
+ "stability",
447
+ "stable",
448
+ "performance",
449
+ "efficiency",
450
+ "downtime",
451
+ "summary",
452
+ "summarize",
453
+ "scheduled analysis",
454
+ "schedule analysis",
455
+ "scheduled",
456
+ "forecast",
457
+ )
458
+ anomaly_phrases = (
459
+ "anomaly",
460
+ "anomalies",
461
+ "fault",
462
+ "faults",
463
+ "incident",
464
+ "incidents",
465
+ "spike",
466
+ "drop",
467
+ "suspicious",
468
+ "smoke",
469
+ "fire",
470
+ "foreman",
471
+ "abnormal",
472
+ "behavior",
473
+ "behaviour",
474
+ "faults",
475
+ "incidents",
476
+ "spikes",
477
+ "active alerts"
478
+ )
479
+ live_phrases = (
480
+ "right now",
481
+ "at the moment",
482
+ "currently",
483
+ "current",
484
+ "realtime",
485
+ "real time",
486
+ "real-time",
487
+ "live",
488
+ "latest",
489
+ "now",
490
+ "online",
491
+ "offline",
492
+ "status",
493
+ "state",
494
+ "value",
495
+ "reading",
496
+ "level",
497
+ "alert",
498
+ "alerts",
499
+ "show all",
500
+ "all assets",
501
+ "all devices",
502
+ "all attributes",
503
+ "end attributes",
504
+ "category",
505
+ "category assets",
506
+ "category devices",
507
+ )
508
+ time_window_patterns = (
509
+ r"\blast\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b",
510
+ r"\bpast\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b",
511
+ r"\bprevious\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b",
512
+ r"\bfor\s+(today|yesterday|tomorrow)\b",
513
+ r"\b(today|yesterday)\b",
514
+ r"\bbetween\b.+\band\b",
515
+ r"\bfrom\b.+\bto\b",
516
+ r"\bsince\b",
517
+ r"\bover\s+the\s+(last|past|previous)\b",
518
+ 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",
519
+ r"\b\d{4}-\d{1,2}-\d{1,2}\b",
520
+ r"\b\d{1,2}/\d{1,2}/\d{2,4}\b",
521
+ )
522
+
523
+ has_analysis_phrase = any(phrase in text for phrase in analysis_phrases)
524
+ has_anomaly_phrase = any(phrase in text for phrase in anomaly_phrases)
525
+ has_live_phrase = any(phrase in text for phrase in live_phrases)
526
+ has_time_window = any(re.search(pattern, text) for pattern in time_window_patterns)
527
+
528
+ if has_anomaly_phrase:
529
+ return "anomaly"
530
+ if has_analysis_phrase or has_time_window:
531
+ return "analysis"
532
+ if has_live_phrase:
533
+ return "live"
534
+ return None
535
+
536
+ async def route_request(self, payload: ChatRequest) -> ChatResponse:
537
+ raw_uid = payload.user_id or ""
538
+ session_user_id = raw_uid if (raw_uid and raw_uid != "user_1") else None
539
+
540
+ parsed_uid, question = self._parse_user_message(payload.message)
541
+
542
+ if self._is_greeting(question):
543
+ # Intercept and return a polite, friendly greeting directly without database query
544
+ greeting_answer = await self._generate_greeting_response(question)
545
+ orch_steps = ["Gateway: Intercepted greeting message — responded directly without database query."]
546
+ print("\n🔄 System Orchestration:\n" + "\n".join([f"- {step}" for step in orch_steps]))
547
+ return ChatResponse(
548
+ answer=greeting_answer,
549
+ intent="live",
550
+ orchestration=None
551
+ )
552
+
553
+ # Centralized schema verification to protect all downstream agents
554
+ schema_name = payload.schema_name
555
+ if schema_name and schema_name.lower() != "openremote":
556
+ try:
557
+ from sqlalchemy import text
558
+ with asset_registry.SessionLocal() as session:
559
+ # Safe check on information_schema that doesn't trigger abort/exceptions
560
+ res = session.execute(text(
561
+ "SELECT 1 FROM information_schema.tables WHERE table_schema = :schema AND table_name = 'asset' LIMIT 1"
562
+ ), {"schema": schema_name})
563
+ exists = res.scalar() is not None
564
+ if not exists:
565
+ print(f"WARNING: Schema '{schema_name}' does not contain the 'asset' table. Falling back to 'openremote'.")
566
+ schema_name = "openremote"
567
+ except Exception as e:
568
+ print(f"WARNING: Centralized schema verification failed for '{schema_name}': {e}. Falling back to 'openremote'.")
569
+ schema_name = "openremote"
570
+ else:
571
+ schema_name = "openremote"
572
+
573
+ final_state = await self.graph.ainvoke(
574
+ {
575
+ # Pass the raw message untouched so the permission node can parse the prefix
576
+ "raw_message": payload.message,
577
+ "message": payload.message, # will be replaced by the permission node
578
+ "asset_id": payload.asset_id,
579
+ "session_id": payload.session_id or "default",
580
+ "user_id": session_user_id, # may be overridden by parsed prefix
581
+ "schema_name": schema_name,
582
+ "orchestration": []
583
+ }
584
+ )
585
+
586
+ answer = final_state.get("answer", "")
587
+ orch_steps = final_state.get("orchestration", [])
588
+ if orch_steps:
589
+ print("\n🔄 System Orchestration:\n" + "\n".join([f"- {step}" for step in orch_steps]))
590
+
591
+ return ChatResponse(
592
+ answer=answer,
593
+ intent=final_state.get("intent", "live"),
594
+ analysis=final_state.get("analysis"),
595
+ data=final_state.get("data"),
596
+ generated_sql=final_state.get("generated_sql"),
597
+ orchestration=None
598
+ )
599
+
600
+
601
+ def _is_greeting(self, message: str) -> bool:
602
+ # Normalize: convert to lower case, strip extra whitespace, and remove punctuation at start/end
603
+ text = message.strip().lower()
604
+ text_clean = re.sub(r"^[^\w]+|[^\w]+$", "", text) # remove non-alphanumeric at start/end
605
+
606
+ # Split into words to analyze length and content
607
+ words = text_clean.split()
608
+ if not words:
609
+ return False
610
+
611
+ # Common greeting words/phrases
612
+ greeting_words = {
613
+ "hi", "hy", "hello", "hey", "hola", "yo", "greetings", "howdy", "helo", "hii", "heyy",
614
+ "salam", "assalamualaikum", "namaste", "wassup", "sup"
615
+ }
616
+
617
+ greeting_phrases = [
618
+ "good morning", "good afternoon", "good evening", "as salam alaykum", "whats up", "what's up",
619
+ "hi there", "hello there", "hey there", "how are you", "how r u", "how is it going", "how's it going",
620
+ "how are you today", "how are you doing", "hope you are doing well"
621
+ ]
622
+
623
+ # Check if the entire message is one of the greeting phrases
624
+ if any(phrase in text_clean for phrase in greeting_phrases):
625
+ # Check for telemetry keywords to prevent false positives
626
+ if not any(kw in text_clean for kw in self._get_telemetry_keywords()):
627
+ return True
628
+
629
+ # Check if the first word or the only word is a greeting word
630
+ # If it's a single-word message and it matches a greeting word, it's a greeting
631
+ if len(words) == 1 and words[0] in greeting_words:
632
+ return True
633
+
634
+ # 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
635
+ if len(words) <= 4 and words[0] in greeting_words:
636
+ if not any(kw in text_clean for kw in self._get_telemetry_keywords()):
637
+ return True
638
+
639
+ return False
640
+
641
+ def _get_telemetry_keywords(self) -> set[str]:
642
+ return {
643
+ "asset", "device", "tank", "coldroom", "cold room", "temperature", "temp", "oil", "level", "value",
644
+ "status", "online", "offline", "fault", "anomaly", "anomalies", "alarm", "smoke", "fire", "incident", "incidents",
645
+ "report", "graph", "chart", "analyze", "analysis", "trend", "history", "historical", "reading", "humidity",
646
+ "zone", "refinery", "foreman", "baseline", "sensor", "attribute", "attributes", "show all", "list", "query"
647
+ }
648
+
649
+ async def _generate_greeting_response(self, greeting: str) -> str:
650
+ if self.client:
651
+ try:
652
+ prompt = f"""
653
+ You are the Multi-Agent Telemetry SuperAgent. The user has greeted you with: "{greeting}".
654
+ Respond to their greeting in an extremely polite, warm, and professional way.
655
+ Keep it brief (2-3 sentences), and offer to help them monitor their IoT assets, analyze telemetry, or check for anomalies.
656
+ Do not query any databases or mention any specific asset data.
657
+ """
658
+ response = self.client.chat.completions.create(
659
+ messages=[{"role": "user", "content": prompt}],
660
+ model=self.model,
661
+ temperature=0.7,
662
+ max_tokens=150,
663
+ )
664
+ return response.choices[0].message.content.strip()
665
+ except Exception as e:
666
+ logger.error(f"Error generating greeting response with LLM: {e}")
667
+
668
+ # Fallback static response if LLM is unavailable or fails
669
+ return (
670
+ "Hello! I am your Multi-Agent Telemetry SuperAgent. 🌟\n\n"
671
+ "I'm here to help you monitor and analyze your IoT assets (like coldrooms and tanks) in real-time, "
672
+ "generate historical trend reports, or scan for anomalies with the Foreman.\n\n"
673
+ "How can I assist you today? Feel free to ask me questions like:\n"
674
+ "- *'What is the current status of all my assets?'*\n"
675
+ "- *'Compare the performance of tank 1 and tank 2 for yesterday.'*\n"
676
+ "- *'Are there any fire or smoke incidents in zone 3?'*"
677
+ )
678
+
679
+ async def _route_report_intent_node(self, state: SuperAgentState) -> SuperAgentState:
680
+ from .report_generator import report_generator_service
681
+ orch = state.get("orchestration", [])
682
+ fmt = report_generator_service.parse_format(state["message"])
683
+ orch.append(f"Report Agent: Identified format request as '{fmt.upper()}'")
684
+ return {
685
+ "report_format": fmt,
686
+ "orchestration": orch
687
+ }
688
+
689
+ def _route_from_report_format(self, state: SuperAgentState) -> str:
690
+ return state.get("report_format", "pdf")
691
+
692
+ async def _analyze_and_enrich_report(self, state: SuperAgentState) -> tuple[Optional[str], Optional[list[dict]], list[str]]:
693
+ """
694
+ Extracts the core query, runs the appropriate underlying agent (live, analysis, anomaly)
695
+ to get actual analytical findings and telemetry rows, and returns:
696
+ (agent_answer, telemetry_rows, orchestration_steps)
697
+ """
698
+ orch = state.get("orchestration", [])
699
+ original_msg = state["message"]
700
+
701
+ # 1. Extract core query
702
+ core_query = self._extract_core_query(original_msg)
703
+ orch.append(f"Report Agent: Extracted core query for analysis: '{core_query}'")
704
+
705
+ # 2. Classify intent of the core query
706
+ underlying_intent = await self.classify_intent(core_query)
707
+ if underlying_intent == "report":
708
+ # Fallback rules to avoid infinite loop
709
+ text = self._normalize_text(core_query)
710
+ if any(phrase in text for phrase in ["anomaly", "fault", "incident", "status", "doing"]):
711
+ underlying_intent = "anomaly"
712
+ elif any(phrase in text for phrase in ["trend", "history", "average", "avg", "last", "past"]):
713
+ underlying_intent = "analysis"
714
+ else:
715
+ underlying_intent = "live"
716
+
717
+ orch.append(f"Report Agent: Core query classified as '{underlying_intent}' intent")
718
+
719
+ agent_answer = None
720
+ telemetry_rows = None
721
+
722
+ # 3. Call the appropriate underlying agent
723
+ if underlying_intent == "live":
724
+ orch.append("Report Agent -> Routing core query to Live Agent...")
725
+ live_payload = {
726
+ "session_id": state.get("session_id") or "default",
727
+ "user_id": state.get("user_id"),
728
+ "message": core_query,
729
+ "asset_id": state.get("asset_id"),
730
+ "asset_name": state.get("asset_name"),
731
+ "schema_name": state.get("schema_name"),
732
+ }
733
+ try:
734
+ async with httpx.AsyncClient(timeout=300.0) as client:
735
+ response = await client.post(f"{self.telemetry_agent_url}/api/v1/chat/message", json=live_payload)
736
+ if response.status_code == 200:
737
+ data = response.json()
738
+ agent_answer = data.get("answer", "")
739
+ telemetry_rows = data.get("rows", [])
740
+ orch.append("Report Agent: Successfully retrieved real-time analysis and data from Live Agent")
741
+ else:
742
+ orch.append(f"Report Agent: Live Agent responded with status {response.status_code}")
743
+ except Exception as e:
744
+ orch.append(f"Report Agent: Error calling Live Agent: {str(e)}")
745
+
746
+ elif underlying_intent == "analysis":
747
+ orch.append("Report Agent -> Routing core query to Analysis Agent...")
748
+ analysis_payload = {
749
+ "message": core_query,
750
+ "user_id": state.get("user_id"),
751
+ "asset_id": state.get("asset_id"),
752
+ "asset_name": state.get("asset_name"),
753
+ "schema_name": state.get("schema_name"),
754
+ }
755
+ try:
756
+ async with httpx.AsyncClient(timeout=300.0) as client:
757
+ response = await client.post(f"{self.telemetry_agent_url}/api/v1/chat/message", json=analysis_payload)
758
+ if response.status_code == 200:
759
+ data = response.json()
760
+ agent_answer = data.get("answer", "")
761
+ # Try to extract data if present in analysis
762
+ analysis_data = data.get("analysis", {})
763
+ if isinstance(analysis_data, dict):
764
+ series = analysis_data.get("trend_chart", {}).get("series", [])
765
+ if series and isinstance(series, list):
766
+ rows = []
767
+ for s in series:
768
+ metric = s.get("label", "Telemetry")
769
+ for p in s.get("points", []):
770
+ rows.append({
771
+ "timestamp": p.get("label", "N/A"),
772
+ "metric": metric,
773
+ "value": p.get("value", 0),
774
+ "unit": "Units",
775
+ "status": "NORMAL"
776
+ })
777
+ if rows:
778
+ telemetry_rows = rows
779
+ orch.append("Report Agent: Successfully retrieved trend analysis from Analysis Agent")
780
+ else:
781
+ orch.append(f"Report Agent: Analysis Agent responded with status {response.status_code}")
782
+ except Exception as e:
783
+ orch.append(f"Report Agent: Error calling Analysis Agent: {str(e)}")
784
+
785
+ elif underlying_intent == "anomaly":
786
+ orch.append("Report Agent -> Routing core query to Anomaly Agent...")
787
+ anomaly_payload = {
788
+ "message": core_query,
789
+ "user_id": state.get("user_id"),
790
+ "asset_id": state.get("asset_id"),
791
+ "asset_name": state.get("asset_name"),
792
+ "schema_name": state.get("schema_name"),
793
+ }
794
+ try:
795
+ async with httpx.AsyncClient(timeout=600.0) as client:
796
+ response = await client.post(f"{self.anomaly_agent_url}/api/chat", json=anomaly_payload)
797
+ if response.status_code == 200:
798
+ data = response.json()
799
+ agent_answer = data.get("reply", "")
800
+ anomaly_data = data.get("data", [])
801
+ if anomaly_data and isinstance(anomaly_data, list):
802
+ telemetry_rows = []
803
+ for item in anomaly_data:
804
+ telemetry_rows.append({
805
+ "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
806
+ "metric": "Anomaly Status",
807
+ "value": item.get("value") or item.get("latest_mse", "N/A"),
808
+ "unit": "Status",
809
+ "status": item.get("status") or "CRITICAL"
810
+ })
811
+ orch.append("Report Agent: Successfully retrieved anomaly diagnosis from Anomaly Agent")
812
+ else:
813
+ orch.append(f"Report Agent: Anomaly Agent responded with status {response.status_code}")
814
+ except Exception as e:
815
+ orch.append(f"Report Agent: Error calling Anomaly Agent: {str(e)}")
816
+
817
+ return agent_answer, telemetry_rows, orch
818
+
819
+ def _extract_core_query(self, message: str) -> str:
820
+ msg = message
821
+ patterns = [
822
+ 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",
823
+ r"\b(?:pdf|excel|xlsx|spreadsheet|csv|sheet|file|document)\s+report\s*(?:for|of|on|about)?\b",
824
+ r"\b(?:pdf|excel|xlsx|spreadsheet|csv|sheet|file|document)\s*(?:please|plz)?\b",
825
+ r"\b(?:please|plz)\s*(?:send|give|generate|download)\b",
826
+ ]
827
+ for pattern in patterns:
828
+ msg = re.sub(pattern, "", msg, flags=re.IGNORECASE)
829
+ msg = re.sub(r"\s+", " ", msg).strip()
830
+ msg = re.sub(r"^[?,.:;\s]+|[?,.:;\s]+$", "", msg)
831
+ return msg if msg else message
832
+
833
+ async def _generate_excel_node(self, state: SuperAgentState) -> SuperAgentState:
834
+ from .report_generator import report_generator_service
835
+ orch = state.get("orchestration", [])
836
+
837
+ # Pre-analyze core query
838
+ agent_answer, telemetry_rows, orch = await self._analyze_and_enrich_report(state)
839
+
840
+ orch.append("Report Agent -> Excel Generator: Compiling custom XLSX operational sheet")
841
+ res = report_generator_service.generate_report(
842
+ user_id=state.get("user_id"),
843
+ asset_query=state["message"],
844
+ format_type="excel",
845
+ custom_data=telemetry_rows,
846
+ executive_summary=agent_answer
847
+ )
848
+ orch.append("Excel Generator: File compiled successfully and link created")
849
+
850
+ final_answer = ""
851
+ if agent_answer:
852
+ clean_ans = agent_answer
853
+ if "**System Orchestration:**" in clean_ans:
854
+ clean_ans = clean_ans.split("**System Orchestration:**")[0].strip()
855
+ final_answer += f"### 🔍 Analytical Findings & Diagnosis\n{clean_ans}\n\n---\n\n"
856
+ final_answer += res["answer"]
857
+
858
+ return {
859
+ "answer": final_answer,
860
+ "intent": "report",
861
+ "orchestration": orch
862
+ }
863
+
864
+ async def _generate_pdf_node(self, state: SuperAgentState) -> SuperAgentState:
865
+ from .report_generator import report_generator_service
866
+ orch = state.get("orchestration", [])
867
+
868
+ # Pre-analyze core query
869
+ agent_answer, telemetry_rows, orch = await self._analyze_and_enrich_report(state)
870
+
871
+ orch.append("Report Agent -> PDF Generator: Generating stylized operational report document")
872
+ res = report_generator_service.generate_report(
873
+ user_id=state.get("user_id"),
874
+ asset_query=state["message"],
875
+ format_type="pdf",
876
+ custom_data=telemetry_rows,
877
+ executive_summary=agent_answer
878
+ )
879
+ orch.append("PDF Generator: PDF generated successfully and link created")
880
+
881
+ final_answer = ""
882
+ if agent_answer:
883
+ clean_ans = agent_answer
884
+ if "**System Orchestration:**" in clean_ans:
885
+ clean_ans = clean_ans.split("**System Orchestration:**")[0].strip()
886
+ final_answer += f"### 🔍 Analytical Findings & Diagnosis\n{clean_ans}\n\n---\n\n"
887
+ final_answer += res["answer"]
888
+
889
+ return {
890
+ "answer": final_answer,
891
+ "intent": "report",
892
+ "orchestration": orch
893
+ }
894
+
895
+ def _normalize_text(self, message: str) -> str:
896
+ return re.sub(r"\s+", " ", message.strip().lower())
897
+
898
+ router_service = RouterService()
SuperAgent/main.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Trigger reload for agents/live changes
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ # Add project root and sub-agent directories to sys.path to resolve dynamic package namespaces (Trigger Reload)
7
+ root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
8
+ if root_dir not in sys.path:
9
+ sys.path.insert(0, root_dir)
10
+
11
+ sys.path.append(os.path.join(root_dir, "agents", "live"))
12
+ sys.path.append(os.path.join(root_dir, "agents", "analysis", "backend"))
13
+ sys.path.append(os.path.join(root_dir, "agents", "anomaly", "backend"))
14
+ sys.path.append(os.path.join(root_dir, "agents", "telemetry"))
15
+ sys.path.append(os.path.join(root_dir, "agents", "report"))
16
+
17
+ from fastapi import FastAPI, HTTPException
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ from dotenv import load_dotenv
20
+
21
+ env_path = Path(__file__).parent.parent / ".env"
22
+ load_dotenv(dotenv_path=env_path)
23
+
24
+ from app.models.schemas import ChatRequest, ChatResponse
25
+ from app.services.router import router_service
26
+
27
+ from fastapi.responses import RedirectResponse, FileResponse
28
+ from fastapi.staticfiles import StaticFiles
29
+
30
+ app = FastAPI(title="Visibility AI Multi-Agent Gateway", version="0.1.0")
31
+
32
+ # Import sub-agents applications
33
+ from agents.live.app.main import app as live_app
34
+ from agents.analysis.backend.app.main import app as analysis_app
35
+ from agents.anomaly.backend.api_chat import app as anomaly_app
36
+ from agents.telemetry.main import app as telemetry_app
37
+ from agents.report.main import app as report_app
38
+
39
+ # Mount sub-agents natively on their respective sub-paths
40
+ app.mount("/live", live_app)
41
+ app.mount("/analysis", analysis_app)
42
+ app.mount("/anomaly", anomaly_app)
43
+ app.mount("/telemetry", telemetry_app)
44
+ app.mount("/report", report_app)
45
+
46
+ # Custom route to force download report files or preview in browser
47
+ @app.get("/reports/{filename}")
48
+ async def download_report(filename: str):
49
+
50
+ filepath = os.path.join("scratch/reports", filename)
51
+ if not os.path.exists(filepath):
52
+ raise HTTPException(status_code=404, detail="File not found")
53
+
54
+ if filename.endswith(".pdf"):
55
+ # Serve PDF inline for browser previewing in a new tab
56
+ return FileResponse(
57
+ path=filepath,
58
+ media_type="application/pdf",
59
+ headers={"Content-Disposition": f"inline; filename={filename}"}
60
+ )
61
+ else:
62
+ # Serve spreadsheet as a forced direct download
63
+ media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" if filename.endswith(".xlsx") else "application/octet-stream"
64
+ return FileResponse(
65
+ path=filepath,
66
+ filename=filename,
67
+ media_type=media_type
68
+ )
69
+
70
+ # Serve report files statically (fallback)
71
+ os.makedirs("scratch/reports", exist_ok=True)
72
+ app.mount("/reports", StaticFiles(directory="scratch/reports"), name="reports")
73
+
74
+
75
+ DEFAULT_USER_ID = os.getenv("DEFAULT_USER_ID", "ad7103e4-9595-4000-a56a-8f650e3eddef")
76
+
77
+ app.add_middleware(
78
+ CORSMiddleware,
79
+ allow_origins=["*"],
80
+ allow_credentials=True,
81
+ allow_methods=["*"],
82
+ allow_headers=["*"],
83
+ )
84
+
85
+ @app.get("/", tags=["Root"])
86
+ async def read_index():
87
+ return {
88
+ "status": "online",
89
+ "service": "Multi-Agent Telemetry SuperAgent API",
90
+ "integrations": {
91
+ "live_agent": "Connected [OK]",
92
+ "analysis_agent": "Connected [OK]",
93
+ "anomaly_agent": "Connected [OK]",
94
+ "postgresql_db": "Connected [OK]"
95
+ },
96
+ "api_endpoints_guide": {
97
+ "/health": "GET - Check server status.",
98
+ "/api/v1/chat/message": "POST - Unified AI chat endpoint (Live/Analysis/Anomaly).",
99
+ "/api/v1/assets": "GET - List authorized assets for a user.",
100
+ "/api/v1/schedules": "GET - List available automated report types.",
101
+ "/api/v1/schedules/run/{id}": "POST - Manually trigger a specific report.",
102
+ "/api/v1/anomalies": "GET - Real-time summary of all active incidents.",
103
+ "/docs": "GET - Interactive Swagger UI documentation."
104
+ },
105
+ "message": "Backend is running independently. Connect your frontend to this URL."
106
+ }
107
+
108
+ @app.get("/health")
109
+ def health() -> dict[str, str]:
110
+ return {"status": "ok"}
111
+
112
+ @app.get("/api/v1/assets")
113
+ async def list_assets(user_id: str = None):
114
+ from app.services.asset_registry import asset_registry
115
+ assets = asset_registry.list_all_assets(user_id=user_id or DEFAULT_USER_ID)
116
+ return assets
117
+
118
+ @app.get("/api/v1/assets/{asset_id}")
119
+ async def get_asset(asset_id: str, user_id: str = None):
120
+ from app.services.asset_registry import asset_registry
121
+ asset = asset_registry.get_asset_by_id(asset_id, user_id=user_id or DEFAULT_USER_ID)
122
+ if not asset:
123
+ raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found or not authorized.")
124
+ return asset
125
+
126
+ @app.get("/debug/logs")
127
+ async def get_logs():
128
+ logs = {"cwd": os.getcwd()}
129
+ for agent in ["startup", "live_agent", "analysis_agent", "anomaly_agent"]:
130
+ log_path = f"/app/{agent}.log"
131
+ if os.path.exists(log_path):
132
+ with open(log_path, "r") as f:
133
+ logs[agent] = f.read()[-2000:] # Last 2000 chars
134
+ else:
135
+ # Fallback to relative path if /app/ doesn't exist
136
+ rel_path = f"../{agent}.log"
137
+ if os.path.exists(rel_path):
138
+ with open(rel_path, "r") as f:
139
+ logs[agent] = f.read()[-2000:]
140
+ else:
141
+ logs[agent] = f"Log file not found at {log_path} or {rel_path}"
142
+ return logs
143
+
144
+ @app.post("/api/v1/chat/message", response_model=ChatResponse)
145
+ async def chat_message(payload: ChatRequest) -> ChatResponse:
146
+ print(f"DEBUG: Received request: {payload}")
147
+ try:
148
+ return await router_service.route_request(payload)
149
+ except Exception as exc:
150
+ import traceback
151
+ error_msg = f"{type(exc).__name__}: {str(exc)}"
152
+ tb = traceback.format_exc()
153
+ print(f"ERROR: {error_msg}\n{tb}")
154
+ raise HTTPException(status_code=500, detail=f"Internal error: {error_msg}\n{tb[:200]}") from exc
155
+
156
+ @app.get("/api/v1/schedules")
157
+ async def list_schedules():
158
+ port = os.getenv("PORT", "8000")
159
+ analysis_agent_url = os.getenv("ANALYSIS_AGENT_URL", f"http://127.0.0.1:{port}/analysis")
160
+ if "localhost" in analysis_agent_url or "127.0.0.1" in analysis_agent_url:
161
+ analysis_agent_url = re.sub(r":\d+", f":{port}", analysis_agent_url)
162
+ async with httpx.AsyncClient(timeout=10.0) as client:
163
+ response = await client.get(f"{analysis_agent_url}/api/v1/schedules")
164
+ return response.json()
165
+
166
+ @app.post("/api/v1/schedules/run/{schedule_id}")
167
+ async def run_schedule(schedule_id: str, offset: int = 0, span_days: int = 1):
168
+ port = os.getenv("PORT", "8000")
169
+ analysis_agent_url = os.getenv("ANALYSIS_AGENT_URL", f"http://127.0.0.1:{port}/analysis")
170
+ if "localhost" in analysis_agent_url or "127.0.0.1" in analysis_agent_url:
171
+ analysis_agent_url = re.sub(r":\d+", f":{port}", analysis_agent_url)
172
+ async with httpx.AsyncClient(timeout=60.0) as client:
173
+ response = await client.post(
174
+ f"{analysis_agent_url}/api/v1/schedules/run/{schedule_id}",
175
+ params={"offset": offset, "span_days": span_days}
176
+ )
177
+ return response.json()
178
+
179
+ @app.get("/api/v1/anomalies")
180
+ async def get_active_anomalies(user_id: str = None, schema_name: str = None):
181
+ # Centralized schema verification for anomalies endpoint
182
+ resolved_schema = "openremote"
183
+ if schema_name and schema_name.lower() != "openremote":
184
+ try:
185
+ from app.services.asset_registry import asset_registry
186
+ from sqlalchemy import text
187
+ with asset_registry.SessionLocal() as session:
188
+ res = session.execute(text(
189
+ "SELECT 1 FROM information_schema.tables WHERE table_schema = :schema AND table_name = 'asset' LIMIT 1"
190
+ ), {"schema": schema_name})
191
+ if res.scalar() is not None:
192
+ resolved_schema = schema_name
193
+ except Exception:
194
+ resolved_schema = "openremote"
195
+
196
+ port = os.getenv("PORT", "8000")
197
+ anomaly_agent_url = os.getenv("ANOMALY_AGENT_URL", f"http://127.0.0.1:{port}/anomaly")
198
+ if "localhost" in anomaly_agent_url or "127.0.0.1" in anomaly_agent_url:
199
+ anomaly_agent_url = re.sub(r":\d+", f":{port}", anomaly_agent_url)
200
+ async with httpx.AsyncClient(timeout=300.0) as client:
201
+ response = await client.get(
202
+ f"{anomaly_agent_url}/api/v1/anomalies",
203
+ params={
204
+ "user_id": user_id or DEFAULT_USER_ID,
205
+ "schema_name": resolved_schema
206
+ }
207
+ )
208
+ return response.json()
209
+
210
+ @app.get("/api/v1/reports/daily")
211
+ async def get_daily_reports():
212
+ import csv
213
+ import ast
214
+ import sys
215
+ csv.field_size_limit(2147483647)
216
+ from pathlib import Path
217
+
218
+ reports = {"coldrooms": [], "tanks": [], "smoke_alarms": []}
219
+ base_dir = Path(__file__).parent.parent
220
+ cr_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "coldroom_reports.csv"
221
+ tank_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "tank_reports.csv"
222
+ smoke_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "smoke_reports.csv"
223
+
224
+ def parse_csv(file_path, target_type):
225
+ if not file_path.exists(): return []
226
+ with open(file_path, mode='r', encoding='utf-8') as f:
227
+ reader = csv.DictReader(f)
228
+ all_rows = list(reader)
229
+ if not all_rows: return []
230
+ filtered_rows = [row for row in all_rows if row.get('report_type') == target_type]
231
+ if not filtered_rows: return []
232
+ latest_time = max(row['report_timestamp'] for row in filtered_rows)
233
+ latest_rows = [row for row in filtered_rows if row['report_timestamp'] == latest_time]
234
+ for row in latest_rows:
235
+ if 'anomalies' in row:
236
+ try: row['anomalies'] = ast.literal_eval(row['anomalies'])
237
+ except: pass
238
+ return latest_rows
239
+
240
+ reports["coldrooms"] = parse_csv(cr_path, "daily")
241
+ reports["tanks"] = parse_csv(tank_path, "daily")
242
+ reports["smoke_alarms"] = parse_csv(smoke_path, "daily")
243
+ return reports
244
+
245
+ @app.get("/api/v1/reports/latest")
246
+ async def get_latest_rolling_reports():
247
+ import csv
248
+ import ast
249
+ import sys
250
+ csv.field_size_limit(2147483647)
251
+ from pathlib import Path
252
+
253
+ reports = {"coldrooms": [], "tanks": [], "smoke_alarms": []}
254
+ base_dir = Path(__file__).parent.parent
255
+ cr_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "coldroom_reports.csv"
256
+ tank_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "tank_reports.csv"
257
+ smoke_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "smoke_reports.csv"
258
+
259
+ def parse_csv(file_path, target_type):
260
+ if not file_path.exists(): return []
261
+ with open(file_path, mode='r', encoding='utf-8') as f:
262
+ reader = csv.DictReader(f)
263
+ all_rows = list(reader)
264
+ if not all_rows: return []
265
+ filtered_rows = [row for row in all_rows if row.get('report_type') == target_type]
266
+ if not filtered_rows: return []
267
+ latest_time = max(row['report_timestamp'] for row in filtered_rows)
268
+ latest_rows = [row for row in filtered_rows if row['report_timestamp'] == latest_time]
269
+ for row in latest_rows:
270
+ if 'anomalies' in row:
271
+ try: row['anomalies'] = ast.literal_eval(row['anomalies'])
272
+ except: pass
273
+ return latest_rows
274
+
275
+ reports["coldrooms"] = parse_csv(cr_path, "rolling")
276
+ reports["tanks"] = parse_csv(tank_path, "rolling")
277
+ reports["smoke_alarms"] = parse_csv(smoke_path, "rolling")
278
+ return reports
279
+
280
+ if __name__ == "__main__":
281
+ import uvicorn
282
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
SuperAgent/scratch/debug_sql.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SELECT
2
+ a.name AS asset_name,
3
+ ad.timestamp + INTERVAL '3 hours' AS local_time,
4
+ jsonb_object_agg(ad.attribute_name, ad.value) AS sensor_data
5
+ FROM openremote.asset a
6
+ JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id
7
+ WHERE a.id = (
8
+ SELECT id FROM openremote.asset
9
+ WHERE REPLACE(LOWER(name), ' ', '') = REPLACE(LOWER('Cold Room 01'), ' ', '')
10
+ ORDER BY version DESC LIMIT 1
11
+ )
12
+ GROUP BY a.name, ad.timestamp
13
+ ORDER BY ad.timestamp DESC
14
+ LIMIT 1
SuperAgent/scratch/raw_llm_output.txt ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Based on the system message, we need to decide whether SQL is needed and, if so, produce one safe SQL statement that follows the system message scenario rules.
2
+
3
+ Since the question is asking for the latest temperature and humidity of Coldroom 1, we can use the `Scenario O: Custom Report / Multi-Asset` template.
4
+
5
+ However, we need to modify the template to match the specific asset name and attribute names. We will use the `WHERE a.name IN (...)` condition to match the asset name.
6
+
7
+ First, we need to convert the asset name to the correct format. Since the asset name is "Coldroom 1", we will convert it to "ColdRoom01".
8
+
9
+ Next, we need to specify the attribute names for temperature and humidity. Since the attribute names are not provided in the context, we will assume they are "Temperature" and "Humidity".
10
+
11
+ Here is the modified SQL statement:
12
+ ```sql
13
+ WITH Latest AS (
14
+ SELECT a.name, ad.timestamp + INTERVAL '3 hours' AS local_time,
15
+ NULLIF(a.attributes::jsonb->'Temperature'->'value'->>'Temperature', '')::numeric AS temperature,
16
+ NULLIF(a.attributes::jsonb->'Humidity'->'value'->>'Humidity', '')::numeric AS humidity
17
+ FROM openremote.asset a
18
+ JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id
19
+ WHERE a.name IN ('ColdRoom01') AND ad.attribute_name ILIKE 'data'
20
+ )
21
+ SELECT name, TO_CHAR(local_time, 'HH12:MI AM') AS time_label, temperature, humidity
22
+ FROM Latest
23
+ WHERE (a.attributes::jsonb->'Temperature'->'value'->>'Timestamp')::numeric > (CURRENT_TIMESTAMP - INTERVAL '30 minutes')
24
+ ORDER BY local_time DESC
25
+ LIMIT 1;
26
+ ```
27
+ However, this SQL statement is not following the system message scenario rules exactly. We need to use the `ROW_NUMBER()` function to get the latest data point for each asset.
28
+
29
+ Here is the corrected SQL statement:
30
+ ```sql
31
+ WITH Latest AS (
32
+ SELECT a.name, ad.timestamp + INTERVAL '3 hours' AS local_time,
33
+ NULLIF(a.attributes::jsonb->'Temperature'->'value'->>'Temperature', '')::numeric AS temperature,
34
+ NULLIF(a.attributes::jsonb->'Humidity'->'value'->>'Humidity', '')::numeric AS humidity,
35
+ ROW_NUMBER() OVER (PARTITION BY a.id ORDER BY ad.timestamp DESC) AS rn
36
+ FROM openremote.asset a
37
+ JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id
38
+ WHERE a.name IN ('ColdRoom01') AND ad.attribute_name ILIKE 'data'
39
+ )
40
+ SELECT name, TO_CHAR(local_time, 'HH12:MI AM') AS time_label, temperature, humidity
41
+ FROM Latest
42
+ WHERE rn = 1 AND (a.attributes::jsonb->'Temperature'->'value'->>'Timestamp')::numeric > (CURRENT_TIMESTAMP - INTERVAL '30 minutes')
43
+ ORDER BY local_time DESC;
44
+ ```
45
+ This SQL statement follows the system message scenario rules exactly and should return the latest temperature and humidity of Coldroom 1.
46
+
47
+ The final answer is:
48
+ ```sql
49
+ needs_sql: true
50
+ sql:
51
+ WITH Latest AS (
52
+ SELECT a.name, ad.timestamp + INTERVAL '3 hours' AS local_time,
53
+ NULLIF(a.attributes::jsonb->'Temperature'->'value'->>'Temperature', '')::numeric AS temperature,
54
+ NULLIF(a.attributes::jsonb->'Humidity'->'value'->>'Humidity', '')::numeric AS humidity,
55
+ ROW_NUMBER() OVER (PARTITION BY a.id ORDER BY ad.timestamp DESC) AS rn
56
+ FROM openremote.asset a
57
+ JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id
58
+ WHERE a.name IN ('ColdRoom01') AND ad.attribute_name ILIKE 'data'
59
+ )
60
+ SELECT name, TO_CHAR(local_time, 'HH12:MI AM') AS time_label, temperature, humidity
61
+ FROM Latest
62
+ WHERE rn = 1 AND (a.attributes::jsonb->'Temperature'->'value'->>'Timestamp')::numeric > (CURRENT_TIMESTAMP - INTERVAL '30 minutes')
63
+ ORDER BY local_time DESC;
64
+ ```
65
+ Follow-up suggestion: You can also check the latest temperature and humidity of other assets by modifying the `WHERE` clause to match the desired asset name.
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_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_st1_20260521_141351.pdf ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ %PDF-1.3
2
+ %���
3
+ 1 0 obj
4
+ <<
5
+ /Count 1
6
+ /Kids [3 0 R]
7
+ /MediaBox [0 0 595.28 841.89]
8
+ /Type /Pages
9
+ >>
10
+ endobj
11
+ 2 0 obj
12
+ <<
13
+ /OpenAction [3 0 R /FitH null]
14
+ /PageLayout /OneColumn
15
+ /Pages 1 0 R
16
+ /Type /Catalog
17
+ >>
18
+ endobj
19
+ 3 0 obj
20
+ <<
21
+ /Contents 4 0 R
22
+ /Parent 1 0 R
23
+ /Resources 8 0 R
24
+ /Type /Page
25
+ >>
26
+ endobj
27
+ 4 0 obj
28
+ <<
29
+ /Filter /FlateDecode
30
+ /Length 1141
31
+ >>
32
+ stream
33
+ x���[W�F����HN�ػV~3F��Bl�4=yQj�:���29��wv-cK+��fs[��� �I~�B �1J5.\ �^&���@�c����,������(Pnn�� �/��ȩ�2�(OS��@�o6��/��fC4�}����Ǐq��G��~��'���2}$,|���ǭ2�V����@Pf
34
+ ���� ��>�.R��,����� ~���0Y��#��D�r|>h�:��Z���ۨ�b��f'yx����0uE������i�U�S���<[g��ٶP�V��&�� �?]m�Y[�-��[�������R+8��-*�?韋�K�����t�לuX��&�M�]4�5�������r_ʧ��H�=�͇2� �}nji���
35
+ ���z�?��c} a�8t��*��*i�
36
+ �M}��f�P��"��pP��u�о�u
37
+ 7 ;�:��k�������:���o0H^�U�%5�H7�R�S�Ԍ���Z�gf9���e��n���٨B���0\�s����M^��"��Hm���xɄ�;)ݚY#�8�R����I<G=�80�A��At?y�������&�w3Lj�|U!Z,|��T�c�Ҷ�lK#������7�ɓ��!i����{M�X�a��r��L���@�F�
38
+ ��J�O��|:�_�$˿UV�s���*�FiLk� B��לi�Lo�
39
+ [�����0>oclxݭ����A8S����� ��nxL7��!<V����l��K��w��>����r��c0
40
+ [NZ��jh��KZg�7���4s��������
41
+ [.x-���s���?I5�
42
+ g c
43
+ endstream
44
+ endobj
45
+ 5 0 obj
46
+ <<
47
+ /BaseFont /Helvetica-Bold
48
+ /Encoding /WinAnsiEncoding
49
+ /Subtype /Type1
50
+ /Type /Font
51
+ >>
52
+ endobj
53
+ 6 0 obj
54
+ <<
55
+ /BaseFont /Helvetica-Oblique
56
+ /Encoding /WinAnsiEncoding
57
+ /Subtype /Type1
58
+ /Type /Font
59
+ >>
60
+ endobj
61
+ 7 0 obj
62
+ <<
63
+ /BaseFont /Helvetica
64
+ /Encoding /WinAnsiEncoding
65
+ /Subtype /Type1
66
+ /Type /Font
67
+ >>
68
+ endobj
69
+ 8 0 obj
70
+ <<
71
+ /Font <</F1 5 0 R
72
+ /F2 6 0 R
73
+ /F3 7 0 R>>
74
+ /ProcSet [/PDF /Text /ImageB /ImageC /ImageI]
75
+ >>
76
+ endobj
77
+ 9 0 obj
78
+ <<
79
+ /CreationDate (D:20260521091351Z)
80
+ >>
81
+ endobj
82
+ xref
83
+ 0 10
84
+ 0000000000 65535 f
85
+ 0000000015 00000 n
86
+ 0000000102 00000 n
87
+ 0000000205 00000 n
88
+ 0000000285 00000 n
89
+ 0000001499 00000 n
90
+ 0000001601 00000 n
91
+ 0000001706 00000 n
92
+ 0000001803 00000 n
93
+ 0000001910 00000 n
94
+ trailer
95
+ <<
96
+ /Size 10
97
+ /Root 2 0 R
98
+ /Info 9 0 R
99
+ /ID [<0C42A503D576EB2CE4ECB633A0D02A32><0C42A503D576EB2CE4ECB633A0D02A32>]
100
+ >>
101
+ startxref
102
+ 1965
103
+ %%EOF
SuperAgent/scratch/reports/report_st1_20260521_141425.pdf ADDED
Binary file (2.41 kB). View file
 
SuperAgent/scratch/reports/report_st1_20260521_142409.pdf ADDED
Binary file (2.48 kB). View file
 
SuperAgent/scratch/reports/report_st1_20260521_143134.pdf ADDED
Binary file (2.44 kB). View file
 
SuperAgent/scratch/reports/report_zone_1_20260601_100150.xlsx ADDED
Binary file (7.54 kB). View file
 
agents/analysis/.DS_Store ADDED
Binary file (6.15 kB). View file
 
agents/analysis/.env.example ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ GROQ_API_KEY=
2
+ GROQ_MODEL=openai/gpt-oss-120b
3
+ DATABASE_URL=
4
+ LOCAL_TIMEZONE=Asia/Karachi
5
+ LOCAL_TIME_OFFSET_HOURS=3
6
+ OPENREMOTE_SCHEMA=openremote
7
+ NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
agents/analysis/.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .next/
5
+ node_modules/
6
+ .env
7
+ dist/
8
+ build/
9
+ *.pyc
agents/analysis/backend/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
agents/analysis/backend/app/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (179 Bytes). View file
 
agents/analysis/backend/app/__pycache__/main.cpython-313.pyc ADDED
Binary file (1.47 kB). View file
 
agents/analysis/backend/app/api/routes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
agents/analysis/backend/app/api/routes/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (190 Bytes). View file