zaheer140 commited on
Commit
ef5bb4e
·
verified ·
1 Parent(s): 9ea91fb

Delete SuperAgent

Browse files
Files changed (36) 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 +0 -1
  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 +0 -19
  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 +0 -324
  15. SuperAgent/app/services/report_generator.py +0 -13
  16. SuperAgent/app/services/router.py +0 -876
  17. SuperAgent/main.py +0 -267
  18. SuperAgent/scratch/debug_sql.txt +0 -14
  19. SuperAgent/scratch/raw_llm_output.txt +0 -65
  20. SuperAgent/scratch/read_pdf.py +0 -44
  21. SuperAgent/scratch/reports/report_coldroom10_20260520_151010.pdf +0 -0
  22. SuperAgent/scratch/reports/report_coldroom10_20260520_151223.pdf +0 -0
  23. SuperAgent/scratch/reports/report_coldroom10_20260520_154908.pdf +0 -0
  24. SuperAgent/scratch/reports/report_coldroom10_20260520_155631.pdf +0 -0
  25. SuperAgent/scratch/reports/report_coldroom1_20260520_161047.pdf +0 -0
  26. SuperAgent/scratch/reports/report_coldroom1_20260520_161326.pdf +0 -0
  27. SuperAgent/scratch/reports/report_coldroom1_20260520_162115.pdf +0 -0
  28. SuperAgent/scratch/reports/report_coldroom1_20260520_164714.pdf +0 -0
  29. SuperAgent/scratch/reports/report_coldroom1_20260520_171604.pdf +0 -0
  30. SuperAgent/scratch/reports/report_coldroom1_20260520_172140.pdf +0 -0
  31. SuperAgent/scratch/reports/report_coldroom1_20260520_172737.pdf +0 -0
  32. SuperAgent/scratch/reports/report_coldroom1_20260521_114641.pdf +0 -0
  33. SuperAgent/scratch/reports/report_st1_20260521_141351.pdf +0 -103
  34. SuperAgent/scratch/reports/report_st1_20260521_141425.pdf +0 -0
  35. SuperAgent/scratch/reports/report_st1_20260521_142409.pdf +0 -0
  36. SuperAgent/scratch/reports/report_st1_20260521_143134.pdf +0 -0
SuperAgent/__pycache__/main.cpython-312.pyc DELETED
Binary file (8.65 kB)
 
SuperAgent/__pycache__/main.cpython-313.pyc DELETED
Binary file (16.3 kB)
 
SuperAgent/app/__init__.py DELETED
@@ -1 +0,0 @@
1
- # Package marker
 
 
SuperAgent/app/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (161 Bytes)
 
SuperAgent/app/__pycache__/__init__.cpython-313.pyc DELETED
Binary file (166 Bytes)
 
SuperAgent/app/models/__pycache__/schemas.cpython-312.pyc DELETED
Binary file (1.17 kB)
 
SuperAgent/app/models/__pycache__/schemas.cpython-313.pyc DELETED
Binary file (1.33 kB)
 
SuperAgent/app/models/schemas.py DELETED
@@ -1,19 +0,0 @@
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 DELETED
Binary file (9.23 kB)
 
SuperAgent/app/services/__pycache__/asset_registry.cpython-313.pyc DELETED
Binary file (17.2 kB)
 
SuperAgent/app/services/__pycache__/report_generator.cpython-313.pyc DELETED
Binary file (777 Bytes)
 
SuperAgent/app/services/__pycache__/router.cpython-312.pyc DELETED
Binary file (21.1 kB)
 
SuperAgent/app/services/__pycache__/router.cpython-313.pyc DELETED
Binary file (42 kB)
 
SuperAgent/app/services/asset_registry.py DELETED
@@ -1,324 +0,0 @@
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 DELETED
@@ -1,13 +0,0 @@
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 DELETED
@@ -1,876 +0,0 @@
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
- # Resolve asset from message (centralized source of truth)
246
- is_all_query = any(kw in state["message"].lower() for kw in ["all assets", "saare assets", "show assets", "list assets", "summary"])
247
-
248
- # 1. Try to find asset mentioned in the message
249
- asset = asset_registry.find_asset(state["message"], state["user_id"]) if not is_all_query else None
250
-
251
- asset_id = None
252
- asset_name = None
253
-
254
- if asset:
255
- asset_id = asset['id']
256
- asset_name = asset['name']
257
- elif state.get("asset_id"):
258
- # 2. If ID was provided in payload but not mentioned, verify it belongs to user
259
- verified_asset = asset_registry.get_asset_by_id(state["asset_id"], state["user_id"])
260
- if verified_asset:
261
- asset_id = verified_asset['id']
262
- asset_name = verified_asset['name']
263
- else:
264
- logger.warning(f"User {state['user_id']} attempted to access unauthorized asset {state['asset_id']}")
265
- asset_id = None
266
- asset_name = None
267
-
268
- orch = state.get("orchestration", [])
269
- orch.append(f"Gateway: Intent classified as '{intent}'")
270
-
271
- if is_all_query:
272
- summary = asset_registry.get_summary(state["user_id"])
273
- orch.append(f"Gateway: 'All Assets' query detected. System Summary: {summary}")
274
- elif asset:
275
- orch.append(f"Gateway: Identified asset '{asset_name}' ({asset_id})")
276
-
277
- return {
278
- "intent": intent or "live",
279
- "asset_id": asset_id,
280
- "asset_name": asset_name,
281
- "orchestration": orch
282
- }
283
-
284
- def _route_from_intent(self, state: SuperAgentState) -> Intent:
285
- return state.get("intent", "live")
286
-
287
- async def _route_live_node(self, state: SuperAgentState) -> SuperAgentState:
288
- orch = state.get("orchestration", [])
289
- orch.append("Gateway -> Live Agent: Fetching real-time telemetry from database")
290
- live_payload = {
291
- "session_id": state.get("session_id") or "default",
292
- "user_id": state.get("user_id"),
293
- "message": state["message"],
294
- "asset_id": state.get("asset_id"),
295
- "asset_name": state.get("asset_name"),
296
- "schema_name": state.get("schema_name"),
297
- }
298
- async with httpx.AsyncClient(timeout=300.0) as client:
299
- response = await client.post(f"{self.telemetry_agent_url}/api/v1/chat/message", json=live_payload)
300
- if response.status_code != 200:
301
- orch.append("Live Agent: Failed to respond")
302
- return {
303
- "answer": f"Live Agent Error: {response.text}",
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
- return {
336
- "answer": f"Analysis Agent Error: {response.text}",
337
- "intent": "analysis",
338
- "orchestration": orch
339
- }
340
- data = response.json()
341
- orch.append("Analysis Agent: Trend analysis complete")
342
-
343
- return {
344
- "answer": data.get("answer", ""),
345
- "intent": "analysis",
346
- "analysis": data.get("analysis"),
347
- "orchestration": orch
348
- }
349
-
350
- async def _route_anomaly_node(self, state: SuperAgentState) -> SuperAgentState:
351
- orch = state.get("orchestration", [])
352
- orch.append("Gateway -> Anomaly Agent (The Foreman): Activating multi-agent industrial supervisor")
353
- anomaly_payload = {
354
- "message": state["message"],
355
- "user_id": state.get("user_id"),
356
- "asset_id": state.get("asset_id"),
357
- "asset_name": state.get("asset_name"),
358
- "schema_name": state.get("schema_name"),
359
- }
360
- async with httpx.AsyncClient(timeout=600.0) as client:
361
- response = await client.post(
362
- f"{self.anomaly_agent_url}/api/chat",
363
- json=anomaly_payload,
364
- )
365
- if response.status_code != 200:
366
- orch.append("Anomaly Agent: Failed to respond")
367
- return {
368
- "answer": f"Anomaly Agent Error: {response.text}",
369
- "intent": "anomaly",
370
- "orchestration": orch
371
- }
372
- data = response.json()
373
- orch.append("Anomaly Agent: Multi-agent reasoning cycle finished")
374
-
375
- return {
376
- "answer": data.get("reply", ""),
377
- "intent": "anomaly",
378
- "data": data.get("data"),
379
- "orchestration": orch
380
- }
381
-
382
- def _classify_with_rules(self, message: str) -> Intent | None:
383
- text = self._normalize_text(message)
384
-
385
- # Check for report generation intent
386
- report_keywords = [
387
- "generate report", "download report", "get report", "create report",
388
- "excel report", "pdf report", "xlsx report", "sheet report", "spreadsheet report",
389
- "generate excel", "generate pdf", "download excel", "download pdf",
390
- "save as pdf", "save as excel", "export to pdf", "export to excel",
391
- "export pdf", "export excel", "make pdf", "make excel", "telemetry pdf", "telemetry excel",
392
- "pdf file", "excel file", "xlsx file", "spreadsheet file", "sheet file",
393
- "pdf", "excel", "xlsx", "spreadsheet", "csv"
394
- ]
395
- if any(kw in text for kw in report_keywords):
396
- return "report"
397
-
398
- # Route coldrooms/tanks/smoke alarm system status, what is happening,
399
- # and how they are doing to "anomaly" so that the anomaly agent is run.
400
- has_specific_asset = any(term in text for term in [
401
- "coldroom", "cold room", "coldrooms", "cold rooms",
402
- "tank", "tanks",
403
- "smoke alarm", "smoke alarms", "smoke detector", "smoke detectors",
404
- "smoke sensor", "smoke sensors", "fire alarm", "fire alarms"
405
- ])
406
- if has_specific_asset:
407
- is_status_query = "status" in text or "state" in text
408
- is_happening_query = "happening" in text or "going on" in text or "what is with" in text
409
- is_doing_query = (
410
- "doing" in text or
411
- "how is" in text or
412
- "how are" in text or
413
- "how's" in text or
414
- "is ok" in text or
415
- "is okay" in text or
416
- "are ok" in text or
417
- "are okay" in text
418
- )
419
- if is_status_query or is_happening_query or is_doing_query:
420
- return "anomaly"
421
-
422
- # Force "live" routing for global asset status summaries
423
- global_status_keywords = ["systemstatus", "statusofall", "allassetsstatus", "alldevicesstatus", "completestatus", "statusreport"]
424
- if any(kw in text.replace(" ", "") for kw in global_status_keywords):
425
- return "live"
426
-
427
- analysis_phrases = (
428
- "analysis",
429
- "analyze",
430
- "analyse",
431
- "trend",
432
- "history",
433
- "historical",
434
- "report",
435
- "graph",
436
- "chart",
437
- "compare",
438
- "comparison",
439
- "average",
440
- "avg",
441
- "minimum",
442
- "maximum",
443
- "variance",
444
- "stability",
445
- "stable",
446
- "performance",
447
- "efficiency",
448
- "downtime",
449
- "summary",
450
- "summarize",
451
- "scheduled analysis",
452
- "schedule analysis",
453
- "scheduled",
454
- "forecast",
455
- )
456
- anomaly_phrases = (
457
- "anomaly",
458
- "anomalies",
459
- "fault",
460
- "faults",
461
- "incident",
462
- "incidents",
463
- "spike",
464
- "drop",
465
- "suspicious",
466
- "smoke",
467
- "fire",
468
- "foreman",
469
- "abnormal",
470
- "behavior",
471
- "behaviour",
472
- "faults",
473
- "incidents",
474
- "spikes",
475
- "active alerts"
476
- )
477
- live_phrases = (
478
- "right now",
479
- "at the moment",
480
- "currently",
481
- "current",
482
- "realtime",
483
- "real time",
484
- "real-time",
485
- "live",
486
- "latest",
487
- "now",
488
- "online",
489
- "offline",
490
- "status",
491
- "state",
492
- "value",
493
- "reading",
494
- "level",
495
- "alert",
496
- "alerts",
497
- "show all",
498
- "all assets",
499
- "all devices",
500
- "all attributes",
501
- "end attributes",
502
- "category",
503
- "category assets",
504
- "category devices",
505
- )
506
- time_window_patterns = (
507
- r"\blast\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b",
508
- r"\bpast\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b",
509
- r"\bprevious\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b",
510
- r"\bfor\s+(today|yesterday|tomorrow)\b",
511
- r"\b(today|yesterday)\b",
512
- r"\bbetween\b.+\band\b",
513
- r"\bfrom\b.+\bto\b",
514
- r"\bsince\b",
515
- r"\bover\s+the\s+(last|past|previous)\b",
516
- 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",
517
- r"\b\d{4}-\d{1,2}-\d{1,2}\b",
518
- r"\b\d{1,2}/\d{1,2}/\d{2,4}\b",
519
- )
520
-
521
- has_analysis_phrase = any(phrase in text for phrase in analysis_phrases)
522
- has_anomaly_phrase = any(phrase in text for phrase in anomaly_phrases)
523
- has_live_phrase = any(phrase in text for phrase in live_phrases)
524
- has_time_window = any(re.search(pattern, text) for pattern in time_window_patterns)
525
-
526
- if has_anomaly_phrase:
527
- return "anomaly"
528
- if has_analysis_phrase or has_time_window:
529
- return "analysis"
530
- if has_live_phrase:
531
- return "live"
532
- return None
533
-
534
- async def route_request(self, payload: ChatRequest) -> ChatResponse:
535
- raw_uid = payload.user_id or ""
536
- session_user_id = raw_uid if (raw_uid and raw_uid != "user_1") else None
537
-
538
- parsed_uid, question = self._parse_user_message(payload.message)
539
-
540
- if self._is_greeting(question):
541
- # Intercept and return a polite, friendly greeting directly without database query
542
- greeting_answer = await self._generate_greeting_response(question)
543
- orch_steps = ["Gateway: Intercepted greeting message — responded directly without database query."]
544
- print("\n🔄 System Orchestration:\n" + "\n".join([f"- {step}" for step in orch_steps]))
545
- return ChatResponse(
546
- answer=greeting_answer,
547
- intent="live",
548
- orchestration=None
549
- )
550
-
551
- final_state = await self.graph.ainvoke(
552
- {
553
- # Pass the raw message untouched so the permission node can parse the prefix
554
- "raw_message": payload.message,
555
- "message": payload.message, # will be replaced by the permission node
556
- "asset_id": payload.asset_id,
557
- "session_id": payload.session_id or "default",
558
- "user_id": session_user_id, # may be overridden by parsed prefix
559
- "schema_name": payload.schema_name,
560
- "orchestration": []
561
- }
562
- )
563
-
564
- answer = final_state.get("answer", "")
565
- orch_steps = final_state.get("orchestration", [])
566
- if orch_steps:
567
- print("\n🔄 System Orchestration:\n" + "\n".join([f"- {step}" for step in orch_steps]))
568
-
569
- return ChatResponse(
570
- answer=answer,
571
- intent=final_state.get("intent", "live"),
572
- analysis=final_state.get("analysis"),
573
- data=final_state.get("data"),
574
- generated_sql=final_state.get("generated_sql"),
575
- orchestration=None
576
- )
577
-
578
-
579
- def _is_greeting(self, message: str) -> bool:
580
- # Normalize: convert to lower case, strip extra whitespace, and remove punctuation at start/end
581
- text = message.strip().lower()
582
- text_clean = re.sub(r"^[^\w]+|[^\w]+$", "", text) # remove non-alphanumeric at start/end
583
-
584
- # Split into words to analyze length and content
585
- words = text_clean.split()
586
- if not words:
587
- return False
588
-
589
- # Common greeting words/phrases
590
- greeting_words = {
591
- "hi", "hy", "hello", "hey", "hola", "yo", "greetings", "howdy", "helo", "hii", "heyy",
592
- "salam", "assalamualaikum", "namaste", "wassup", "sup"
593
- }
594
-
595
- greeting_phrases = [
596
- "good morning", "good afternoon", "good evening", "as salam alaykum", "whats up", "what's up",
597
- "hi there", "hello there", "hey there", "how are you", "how r u", "how is it going", "how's it going",
598
- "how are you today", "how are you doing", "hope you are doing well"
599
- ]
600
-
601
- # Check if the entire message is one of the greeting phrases
602
- if any(phrase in text_clean for phrase in greeting_phrases):
603
- # Check for telemetry keywords to prevent false positives
604
- if not any(kw in text_clean for kw in self._get_telemetry_keywords()):
605
- return True
606
-
607
- # Check if the first word or the only word is a greeting word
608
- # If it's a single-word message and it matches a greeting word, it's a greeting
609
- if len(words) == 1 and words[0] in greeting_words:
610
- return True
611
-
612
- # 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
613
- if len(words) <= 4 and words[0] in greeting_words:
614
- if not any(kw in text_clean for kw in self._get_telemetry_keywords()):
615
- return True
616
-
617
- return False
618
-
619
- def _get_telemetry_keywords(self) -> set[str]:
620
- return {
621
- "asset", "device", "tank", "coldroom", "cold room", "temperature", "temp", "oil", "level", "value",
622
- "status", "online", "offline", "fault", "anomaly", "anomalies", "alarm", "smoke", "fire", "incident", "incidents",
623
- "report", "graph", "chart", "analyze", "analysis", "trend", "history", "historical", "reading", "humidity",
624
- "zone", "refinery", "foreman", "baseline", "sensor", "attribute", "attributes", "show all", "list", "query"
625
- }
626
-
627
- async def _generate_greeting_response(self, greeting: str) -> str:
628
- if self.client:
629
- try:
630
- prompt = f"""
631
- You are the Multi-Agent Telemetry SuperAgent. The user has greeted you with: "{greeting}".
632
- Respond to their greeting in an extremely polite, warm, and professional way.
633
- Keep it brief (2-3 sentences), and offer to help them monitor their IoT assets, analyze telemetry, or check for anomalies.
634
- Do not query any databases or mention any specific asset data.
635
- """
636
- response = self.client.chat.completions.create(
637
- messages=[{"role": "user", "content": prompt}],
638
- model=self.model,
639
- temperature=0.7,
640
- max_tokens=150,
641
- )
642
- return response.choices[0].message.content.strip()
643
- except Exception as e:
644
- logger.error(f"Error generating greeting response with LLM: {e}")
645
-
646
- # Fallback static response if LLM is unavailable or fails
647
- return (
648
- "Hello! I am your Multi-Agent Telemetry SuperAgent. 🌟\n\n"
649
- "I'm here to help you monitor and analyze your IoT assets (like coldrooms and tanks) in real-time, "
650
- "generate historical trend reports, or scan for anomalies with the Foreman.\n\n"
651
- "How can I assist you today? Feel free to ask me questions like:\n"
652
- "- *'What is the current status of all my assets?'*\n"
653
- "- *'Compare the performance of tank 1 and tank 2 for yesterday.'*\n"
654
- "- *'Are there any fire or smoke incidents in zone 3?'*"
655
- )
656
-
657
- async def _route_report_intent_node(self, state: SuperAgentState) -> SuperAgentState:
658
- from .report_generator import report_generator_service
659
- orch = state.get("orchestration", [])
660
- fmt = report_generator_service.parse_format(state["message"])
661
- orch.append(f"Report Agent: Identified format request as '{fmt.upper()}'")
662
- return {
663
- "report_format": fmt,
664
- "orchestration": orch
665
- }
666
-
667
- def _route_from_report_format(self, state: SuperAgentState) -> str:
668
- return state.get("report_format", "pdf")
669
-
670
- async def _analyze_and_enrich_report(self, state: SuperAgentState) -> tuple[Optional[str], Optional[list[dict]], list[str]]:
671
- """
672
- Extracts the core query, runs the appropriate underlying agent (live, analysis, anomaly)
673
- to get actual analytical findings and telemetry rows, and returns:
674
- (agent_answer, telemetry_rows, orchestration_steps)
675
- """
676
- orch = state.get("orchestration", [])
677
- original_msg = state["message"]
678
-
679
- # 1. Extract core query
680
- core_query = self._extract_core_query(original_msg)
681
- orch.append(f"Report Agent: Extracted core query for analysis: '{core_query}'")
682
-
683
- # 2. Classify intent of the core query
684
- underlying_intent = await self.classify_intent(core_query)
685
- if underlying_intent == "report":
686
- # Fallback rules to avoid infinite loop
687
- text = self._normalize_text(core_query)
688
- if any(phrase in text for phrase in ["anomaly", "fault", "incident", "status", "doing"]):
689
- underlying_intent = "anomaly"
690
- elif any(phrase in text for phrase in ["trend", "history", "average", "avg", "last", "past"]):
691
- underlying_intent = "analysis"
692
- else:
693
- underlying_intent = "live"
694
-
695
- orch.append(f"Report Agent: Core query classified as '{underlying_intent}' intent")
696
-
697
- agent_answer = None
698
- telemetry_rows = None
699
-
700
- # 3. Call the appropriate underlying agent
701
- if underlying_intent == "live":
702
- orch.append("Report Agent -> Routing core query to Live Agent...")
703
- live_payload = {
704
- "session_id": state.get("session_id") or "default",
705
- "user_id": state.get("user_id"),
706
- "message": core_query,
707
- "asset_id": state.get("asset_id"),
708
- "asset_name": state.get("asset_name"),
709
- "schema_name": state.get("schema_name"),
710
- }
711
- try:
712
- async with httpx.AsyncClient(timeout=300.0) as client:
713
- response = await client.post(f"{self.telemetry_agent_url}/api/v1/chat/message", json=live_payload)
714
- if response.status_code == 200:
715
- data = response.json()
716
- agent_answer = data.get("answer", "")
717
- telemetry_rows = data.get("rows", [])
718
- orch.append("Report Agent: Successfully retrieved real-time analysis and data from Live Agent")
719
- else:
720
- orch.append(f"Report Agent: Live Agent responded with status {response.status_code}")
721
- except Exception as e:
722
- orch.append(f"Report Agent: Error calling Live Agent: {str(e)}")
723
-
724
- elif underlying_intent == "analysis":
725
- orch.append("Report Agent -> Routing core query to Analysis Agent...")
726
- analysis_payload = {
727
- "message": core_query,
728
- "user_id": state.get("user_id"),
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=analysis_payload)
736
- if response.status_code == 200:
737
- data = response.json()
738
- agent_answer = data.get("answer", "")
739
- # Try to extract data if present in analysis
740
- analysis_data = data.get("analysis", {})
741
- if isinstance(analysis_data, dict):
742
- series = analysis_data.get("trend_chart", {}).get("series", [])
743
- if series and isinstance(series, list):
744
- rows = []
745
- for s in series:
746
- metric = s.get("label", "Telemetry")
747
- for p in s.get("points", []):
748
- rows.append({
749
- "timestamp": p.get("label", "N/A"),
750
- "metric": metric,
751
- "value": p.get("value", 0),
752
- "unit": "Units",
753
- "status": "NORMAL"
754
- })
755
- if rows:
756
- telemetry_rows = rows
757
- orch.append("Report Agent: Successfully retrieved trend analysis from Analysis Agent")
758
- else:
759
- orch.append(f"Report Agent: Analysis Agent responded with status {response.status_code}")
760
- except Exception as e:
761
- orch.append(f"Report Agent: Error calling Analysis Agent: {str(e)}")
762
-
763
- elif underlying_intent == "anomaly":
764
- orch.append("Report Agent -> Routing core query to Anomaly Agent...")
765
- anomaly_payload = {
766
- "message": core_query,
767
- "user_id": state.get("user_id"),
768
- "asset_id": state.get("asset_id"),
769
- "asset_name": state.get("asset_name"),
770
- "schema_name": state.get("schema_name"),
771
- }
772
- try:
773
- async with httpx.AsyncClient(timeout=600.0) as client:
774
- response = await client.post(f"{self.anomaly_agent_url}/api/chat", json=anomaly_payload)
775
- if response.status_code == 200:
776
- data = response.json()
777
- agent_answer = data.get("reply", "")
778
- anomaly_data = data.get("data", [])
779
- if anomaly_data and isinstance(anomaly_data, list):
780
- telemetry_rows = []
781
- for item in anomaly_data:
782
- telemetry_rows.append({
783
- "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
784
- "metric": "Anomaly Status",
785
- "value": item.get("value") or item.get("latest_mse", "N/A"),
786
- "unit": "Status",
787
- "status": item.get("status") or "CRITICAL"
788
- })
789
- orch.append("Report Agent: Successfully retrieved anomaly diagnosis from Anomaly Agent")
790
- else:
791
- orch.append(f"Report Agent: Anomaly Agent responded with status {response.status_code}")
792
- except Exception as e:
793
- orch.append(f"Report Agent: Error calling Anomaly Agent: {str(e)}")
794
-
795
- return agent_answer, telemetry_rows, orch
796
-
797
- def _extract_core_query(self, message: str) -> str:
798
- msg = message
799
- patterns = [
800
- 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",
801
- r"\b(?:pdf|excel|xlsx|spreadsheet|csv|sheet|file|document)\s+report\s*(?:for|of|on|about)?\b",
802
- r"\b(?:pdf|excel|xlsx|spreadsheet|csv|sheet|file|document)\s*(?:please|plz)?\b",
803
- r"\b(?:please|plz)\s*(?:send|give|generate|download)\b",
804
- ]
805
- for pattern in patterns:
806
- msg = re.sub(pattern, "", msg, flags=re.IGNORECASE)
807
- msg = re.sub(r"\s+", " ", msg).strip()
808
- msg = re.sub(r"^[?,.:;\s]+|[?,.:;\s]+$", "", msg)
809
- return msg if msg else message
810
-
811
- async def _generate_excel_node(self, state: SuperAgentState) -> SuperAgentState:
812
- from .report_generator import report_generator_service
813
- orch = state.get("orchestration", [])
814
-
815
- # Pre-analyze core query
816
- agent_answer, telemetry_rows, orch = await self._analyze_and_enrich_report(state)
817
-
818
- orch.append("Report Agent -> Excel Generator: Compiling custom XLSX operational sheet")
819
- res = report_generator_service.generate_report(
820
- user_id=state.get("user_id"),
821
- asset_query=state["message"],
822
- format_type="excel",
823
- custom_data=telemetry_rows,
824
- executive_summary=agent_answer
825
- )
826
- orch.append("Excel Generator: File compiled successfully and link created")
827
-
828
- final_answer = ""
829
- if agent_answer:
830
- clean_ans = agent_answer
831
- if "**System Orchestration:**" in clean_ans:
832
- clean_ans = clean_ans.split("**System Orchestration:**")[0].strip()
833
- final_answer += f"### 🔍 Analytical Findings & Diagnosis\n{clean_ans}\n\n---\n\n"
834
- final_answer += res["answer"]
835
-
836
- return {
837
- "answer": final_answer,
838
- "intent": "report",
839
- "orchestration": orch
840
- }
841
-
842
- async def _generate_pdf_node(self, state: SuperAgentState) -> SuperAgentState:
843
- from .report_generator import report_generator_service
844
- orch = state.get("orchestration", [])
845
-
846
- # Pre-analyze core query
847
- agent_answer, telemetry_rows, orch = await self._analyze_and_enrich_report(state)
848
-
849
- orch.append("Report Agent -> PDF Generator: Generating stylized operational report document")
850
- res = report_generator_service.generate_report(
851
- user_id=state.get("user_id"),
852
- asset_query=state["message"],
853
- format_type="pdf",
854
- custom_data=telemetry_rows,
855
- executive_summary=agent_answer
856
- )
857
- orch.append("PDF Generator: PDF generated successfully and link created")
858
-
859
- final_answer = ""
860
- if agent_answer:
861
- clean_ans = agent_answer
862
- if "**System Orchestration:**" in clean_ans:
863
- clean_ans = clean_ans.split("**System Orchestration:**")[0].strip()
864
- final_answer += f"### 🔍 Analytical Findings & Diagnosis\n{clean_ans}\n\n---\n\n"
865
- final_answer += res["answer"]
866
-
867
- return {
868
- "answer": final_answer,
869
- "intent": "report",
870
- "orchestration": orch
871
- }
872
-
873
- def _normalize_text(self, message: str) -> str:
874
- return re.sub(r"\s+", " ", message.strip().lower())
875
-
876
- router_service = RouterService()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
SuperAgent/main.py DELETED
@@ -1,267 +0,0 @@
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
- port = os.getenv("PORT", "8000")
182
- anomaly_agent_url = os.getenv("ANOMALY_AGENT_URL", f"http://127.0.0.1:{port}/anomaly")
183
- if "localhost" in anomaly_agent_url or "127.0.0.1" in anomaly_agent_url:
184
- anomaly_agent_url = re.sub(r":\d+", f":{port}", anomaly_agent_url)
185
- async with httpx.AsyncClient(timeout=300.0) as client:
186
- response = await client.get(
187
- f"{anomaly_agent_url}/api/v1/anomalies",
188
- params={
189
- "user_id": user_id or DEFAULT_USER_ID,
190
- "schema_name": schema_name
191
- }
192
- )
193
- return response.json()
194
-
195
- @app.get("/api/v1/reports/daily")
196
- async def get_daily_reports():
197
- import csv
198
- import ast
199
- import sys
200
- csv.field_size_limit(2147483647)
201
- from pathlib import Path
202
-
203
- reports = {"coldrooms": [], "tanks": [], "smoke_alarms": []}
204
- base_dir = Path(__file__).parent.parent
205
- cr_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "coldroom_reports.csv"
206
- tank_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "tank_reports.csv"
207
- smoke_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "smoke_reports.csv"
208
-
209
- def parse_csv(file_path, target_type):
210
- if not file_path.exists(): return []
211
- with open(file_path, mode='r', encoding='utf-8') as f:
212
- reader = csv.DictReader(f)
213
- all_rows = list(reader)
214
- if not all_rows: return []
215
- filtered_rows = [row for row in all_rows if row.get('report_type') == target_type]
216
- if not filtered_rows: return []
217
- latest_time = max(row['report_timestamp'] for row in filtered_rows)
218
- latest_rows = [row for row in filtered_rows if row['report_timestamp'] == latest_time]
219
- for row in latest_rows:
220
- if 'anomalies' in row:
221
- try: row['anomalies'] = ast.literal_eval(row['anomalies'])
222
- except: pass
223
- return latest_rows
224
-
225
- reports["coldrooms"] = parse_csv(cr_path, "daily")
226
- reports["tanks"] = parse_csv(tank_path, "daily")
227
- reports["smoke_alarms"] = parse_csv(smoke_path, "daily")
228
- return reports
229
-
230
- @app.get("/api/v1/reports/latest")
231
- async def get_latest_rolling_reports():
232
- import csv
233
- import ast
234
- import sys
235
- csv.field_size_limit(2147483647)
236
- from pathlib import Path
237
-
238
- reports = {"coldrooms": [], "tanks": [], "smoke_alarms": []}
239
- base_dir = Path(__file__).parent.parent
240
- cr_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "coldroom_reports.csv"
241
- tank_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "tank_reports.csv"
242
- smoke_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "smoke_reports.csv"
243
-
244
- def parse_csv(file_path, target_type):
245
- if not file_path.exists(): return []
246
- with open(file_path, mode='r', encoding='utf-8') as f:
247
- reader = csv.DictReader(f)
248
- all_rows = list(reader)
249
- if not all_rows: return []
250
- filtered_rows = [row for row in all_rows if row.get('report_type') == target_type]
251
- if not filtered_rows: return []
252
- latest_time = max(row['report_timestamp'] for row in filtered_rows)
253
- latest_rows = [row for row in filtered_rows if row['report_timestamp'] == latest_time]
254
- for row in latest_rows:
255
- if 'anomalies' in row:
256
- try: row['anomalies'] = ast.literal_eval(row['anomalies'])
257
- except: pass
258
- return latest_rows
259
-
260
- reports["coldrooms"] = parse_csv(cr_path, "rolling")
261
- reports["tanks"] = parse_csv(tank_path, "rolling")
262
- reports["smoke_alarms"] = parse_csv(smoke_path, "rolling")
263
- return reports
264
-
265
- if __name__ == "__main__":
266
- import uvicorn
267
- uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
SuperAgent/scratch/debug_sql.txt DELETED
@@ -1,14 +0,0 @@
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 DELETED
@@ -1,65 +0,0 @@
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 DELETED
@@ -1,44 +0,0 @@
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_coldroom10_20260520_151010.pdf DELETED
Binary file (4.75 kB)
 
SuperAgent/scratch/reports/report_coldroom10_20260520_151223.pdf DELETED
Binary file (4.75 kB)
 
SuperAgent/scratch/reports/report_coldroom10_20260520_154908.pdf DELETED
Binary file (4.76 kB)
 
SuperAgent/scratch/reports/report_coldroom10_20260520_155631.pdf DELETED
Binary file (4.72 kB)
 
SuperAgent/scratch/reports/report_coldroom1_20260520_161047.pdf DELETED
Binary file (2.86 kB)
 
SuperAgent/scratch/reports/report_coldroom1_20260520_161326.pdf DELETED
Binary file (2.86 kB)
 
SuperAgent/scratch/reports/report_coldroom1_20260520_162115.pdf DELETED
Binary file (4.73 kB)
 
SuperAgent/scratch/reports/report_coldroom1_20260520_164714.pdf DELETED
Binary file (2.94 kB)
 
SuperAgent/scratch/reports/report_coldroom1_20260520_171604.pdf DELETED
Binary file (22.8 kB)
 
SuperAgent/scratch/reports/report_coldroom1_20260520_172140.pdf DELETED
Binary file (10.1 kB)
 
SuperAgent/scratch/reports/report_coldroom1_20260520_172737.pdf DELETED
Binary file (2.77 kB)
 
SuperAgent/scratch/reports/report_coldroom1_20260521_114641.pdf DELETED
Binary file (2.38 kB)
 
SuperAgent/scratch/reports/report_st1_20260521_141351.pdf DELETED
@@ -1,103 +0,0 @@
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 DELETED
Binary file (2.41 kB)
 
SuperAgent/scratch/reports/report_st1_20260521_142409.pdf DELETED
Binary file (2.48 kB)
 
SuperAgent/scratch/reports/report_st1_20260521_143134.pdf DELETED
Binary file (2.44 kB)