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