Spaces:
Runtime error
Runtime error
Upload 408 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- SuperAgent/__pycache__/main.cpython-312.pyc +0 -0
- SuperAgent/__pycache__/main.cpython-313.pyc +0 -0
- SuperAgent/app/__init__.py +1 -0
- SuperAgent/app/__pycache__/__init__.cpython-312.pyc +0 -0
- SuperAgent/app/__pycache__/__init__.cpython-313.pyc +0 -0
- SuperAgent/app/models/__pycache__/schemas.cpython-312.pyc +0 -0
- SuperAgent/app/models/__pycache__/schemas.cpython-313.pyc +0 -0
- SuperAgent/app/models/schemas.py +19 -0
- SuperAgent/app/services/__pycache__/asset_registry.cpython-312.pyc +0 -0
- SuperAgent/app/services/__pycache__/asset_registry.cpython-313.pyc +0 -0
- SuperAgent/app/services/__pycache__/report_generator.cpython-313.pyc +0 -0
- SuperAgent/app/services/__pycache__/router.cpython-312.pyc +0 -0
- SuperAgent/app/services/__pycache__/router.cpython-313.pyc +0 -0
- SuperAgent/app/services/asset_registry.py +324 -0
- SuperAgent/app/services/report_generator.py +13 -0
- SuperAgent/app/services/router.py +898 -0
- SuperAgent/main.py +282 -0
- SuperAgent/scratch/debug_sql.txt +14 -0
- SuperAgent/scratch/raw_llm_output.txt +65 -0
- SuperAgent/scratch/read_pdf.py +44 -0
- SuperAgent/scratch/reports/report_cold_room_02_20260601_111023.pdf +0 -0
- SuperAgent/scratch/reports/report_cold_room_02_20260601_112724.pdf +0 -0
- SuperAgent/scratch/reports/report_cold_room_03_20260601_110725.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom10_20260520_151010.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom10_20260520_151223.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom10_20260520_154908.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom10_20260520_155631.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom1_20260520_161047.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom1_20260520_161326.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom1_20260520_162115.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom1_20260520_164714.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom1_20260520_171604.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom1_20260520_172140.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom1_20260520_172737.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom1_20260521_114641.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom1_20260601_104530.pdf +0 -0
- SuperAgent/scratch/reports/report_coldroom1_20260601_105254.pdf +0 -0
- SuperAgent/scratch/reports/report_st1_20260521_141351.pdf +103 -0
- SuperAgent/scratch/reports/report_st1_20260521_141425.pdf +0 -0
- SuperAgent/scratch/reports/report_st1_20260521_142409.pdf +0 -0
- SuperAgent/scratch/reports/report_st1_20260521_143134.pdf +0 -0
- SuperAgent/scratch/reports/report_zone_1_20260601_100150.xlsx +0 -0
- agents/analysis/.DS_Store +0 -0
- agents/analysis/.env.example +7 -0
- agents/analysis/.gitignore +9 -0
- agents/analysis/backend/app/__init__.py +1 -0
- agents/analysis/backend/app/__pycache__/__init__.cpython-313.pyc +0 -0
- agents/analysis/backend/app/__pycache__/main.cpython-313.pyc +0 -0
- agents/analysis/backend/app/api/routes/__init__.py +1 -0
- 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 |
+
gc
|
| 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
|
|
|