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