diff --git a/SuperAgent/__pycache__/main.cpython-312.pyc b/SuperAgent/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4fffd75d0dc7a6a9ff360c0591b057f4cb5aa273 Binary files /dev/null and b/SuperAgent/__pycache__/main.cpython-312.pyc differ diff --git a/SuperAgent/__pycache__/main.cpython-313.pyc b/SuperAgent/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7df0400dd58103d6fc420795b34386508420879b Binary files /dev/null and b/SuperAgent/__pycache__/main.cpython-313.pyc differ diff --git a/SuperAgent/app/__init__.py b/SuperAgent/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..cb8c941b850734e860ff6817417f7c35e9799e1b --- /dev/null +++ b/SuperAgent/app/__init__.py @@ -0,0 +1 @@ +# Package marker diff --git a/SuperAgent/app/__pycache__/__init__.cpython-312.pyc b/SuperAgent/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0509c3a3b49ddfb9da1d2388d4c4a2851e706501 Binary files /dev/null and b/SuperAgent/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/SuperAgent/app/__pycache__/__init__.cpython-313.pyc b/SuperAgent/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb5f43330583c2a56ba3f4af4bc7d3cb6f1678d1 Binary files /dev/null and b/SuperAgent/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/SuperAgent/app/models/__pycache__/schemas.cpython-312.pyc b/SuperAgent/app/models/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b5db14e7c0d8d1ed0a26f39c131d7ecfd8624cb1 Binary files /dev/null and b/SuperAgent/app/models/__pycache__/schemas.cpython-312.pyc differ diff --git a/SuperAgent/app/models/__pycache__/schemas.cpython-313.pyc b/SuperAgent/app/models/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..58d2adabb0026d00868d39e69468203d15957c2e Binary files /dev/null and b/SuperAgent/app/models/__pycache__/schemas.cpython-313.pyc differ diff --git a/SuperAgent/app/models/schemas.py b/SuperAgent/app/models/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..c7d065d2fe198f8285ffca27f1aa8d2e89b184e6 --- /dev/null +++ b/SuperAgent/app/models/schemas.py @@ -0,0 +1,24 @@ +from typing import Any, Literal, Optional +from pydantic import BaseModel, Field + +# Reuse the basic structure for compatibility +class ChatRequest(BaseModel): + message: str + asset_id: Optional[str] = None + session_id: Optional[str] = "default" + user_id: Optional[str] = None # Resolved from "User_ID: question" prefix or DEFAULT_USER_ID env var + + +class ChatResponse(BaseModel): + answer: str + intent: Literal["live", "analysis", "anomaly", "report"] + analysis: Optional[Any] = None # Will hold AnalysisResponse if intent is analysis + data: Optional[Any] = None # Will hold rows if intent is live, or complex reports if anomaly + generated_sql: Optional[str] = None + orchestration: Optional[list[str]] = None + +class SessionCreateRequest(BaseModel): + user_id: Optional[str] = None + +class SessionTitleUpdateRequest(BaseModel): + title: str diff --git a/SuperAgent/app/services/__pycache__/asset_registry.cpython-312.pyc b/SuperAgent/app/services/__pycache__/asset_registry.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7eae287d9cd5b31d4579f235a073afc8c59d7c96 Binary files /dev/null and b/SuperAgent/app/services/__pycache__/asset_registry.cpython-312.pyc differ diff --git a/SuperAgent/app/services/__pycache__/asset_registry.cpython-313.pyc b/SuperAgent/app/services/__pycache__/asset_registry.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b0fe84eaaeb577840e8288554b3c4757a2a5c79 Binary files /dev/null and b/SuperAgent/app/services/__pycache__/asset_registry.cpython-313.pyc differ diff --git a/SuperAgent/app/services/__pycache__/email_service.cpython-313.pyc b/SuperAgent/app/services/__pycache__/email_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4601ebce58716aca51c8fb5df7f87ddba85571ad Binary files /dev/null and b/SuperAgent/app/services/__pycache__/email_service.cpython-313.pyc differ diff --git a/SuperAgent/app/services/__pycache__/report_generator.cpython-313.pyc b/SuperAgent/app/services/__pycache__/report_generator.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e496b8d905943c44b79ef9d59a1eaeed8c7568fc Binary files /dev/null and b/SuperAgent/app/services/__pycache__/report_generator.cpython-313.pyc differ diff --git a/SuperAgent/app/services/__pycache__/router.cpython-312.pyc b/SuperAgent/app/services/__pycache__/router.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..84fa2ea801466c3510fe368e596c214efe581b5d Binary files /dev/null and b/SuperAgent/app/services/__pycache__/router.cpython-312.pyc differ diff --git a/SuperAgent/app/services/__pycache__/router.cpython-313.pyc b/SuperAgent/app/services/__pycache__/router.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1992d87d15564b7c49ecfe34d804c998d3fcd3d5 Binary files /dev/null and b/SuperAgent/app/services/__pycache__/router.cpython-313.pyc differ diff --git a/SuperAgent/app/services/asset_registry.py b/SuperAgent/app/services/asset_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..395b6cba53b9a91d93fc63f9043d58d80ecc2cfc --- /dev/null +++ b/SuperAgent/app/services/asset_registry.py @@ -0,0 +1,390 @@ +import os +import re +import time +import logging +from typing import Dict, List, Optional, Any +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +logger = logging.getLogger(__name__) + +class AssetRegistry: + """ + Centralized Asset Registry for the SuperAgent. + Standardizes asset naming and mapping across all agents. + """ + def __init__(self): + db_url = os.environ.get('DATABASE_URL', 'postgresql://postgres:postgres@109.176.197.144:55432/openremote') + if db_url.startswith("postgresql://"): + db_url = db_url.replace("postgresql://", "postgresql+psycopg://", 1) + + self.schema = os.environ.get('OPENREMOTE_SCHEMA', 'openremote') + self.engine = create_engine(db_url, pool_pre_ping=True) + self.SessionLocal = sessionmaker(bind=self.engine) + + self._cache: List[Dict[str, Any]] = [] + self._mappings: Dict[str, Dict[str, Any]] = {} # id -> asset + self._name_map: Dict[str, str] = {} # normalized_name -> id + self._last_load = 0 + self._ttl = 300 # 5 minutes + self._user_cache: Dict[str, List[Dict[str, Any]]] = {} # user_id -> allowed_assets + self._user_last_load: Dict[str, float] = {} + + def _load_user_assets(self, user_id: str): + now = time.time() + if user_id in self._user_cache and (now - self._user_last_load.get(user_id, 0) < self._ttl): + return + + query = text(f""" + WITH RECURSIVE hierarchy AS ( + SELECT a.id::text, a.name, a.parent_id::text, a.attributes::jsonb AS attributes + FROM {self.schema}.asset a + JOIN {self.schema}.user_asset_link ual ON a.id = ual.asset_id + WHERE ual.user_id = :user_id + + UNION + + SELECT child.id::text, child.name, child.parent_id::text, child.attributes::jsonb AS attributes + FROM {self.schema}.asset child + JOIN hierarchy h ON child.parent_id = h.id + ) + SELECT DISTINCT id, name, parent_id, attributes + FROM hierarchy + ORDER BY name + """) + + try: + with self.SessionLocal() as session: + result = session.execute(query, {"user_id": user_id}) + assets = [dict(row) for row in result.mappings().all()] + + self._user_cache[user_id] = assets + self._user_last_load[user_id] = now + logger.info(f"Asset Registry: Loaded {len(assets)} assets for user {user_id}.") + except Exception as e: + logger.error(f"Asset Registry: Failed to load assets for user {user_id}: {e}") + self._user_cache[user_id] = [] + + # def normalize_name(self, name: str) -> str: + # """Standardizes name for fuzzy matching.""" + # if not name: return "" + # # Remove special characters and spaces, lowercase + # return re.sub(r'[^a-zA-Z0-9]', '', name).lower() + + def normalize_name(self, name: str) -> str: + """Standardizes name for fuzzy matching.""" + if not name: return "" + # Remove special characters and spaces, lowercase + normalized = re.sub(r'[^a-zA-Z0-9]', '', name).lower() + # Remove leading zeros from numbers (e.g. coldroom01 -> coldroom1, but keeps 102 as 102) + normalized = re.sub(r'(? Dict[str, Any]: + """Returns a statistical summary of assets allowed for the user.""" + self._load_user_assets(user_id) + assets = self._user_cache.get(user_id, []) + + stats = { + "Total": len(assets), + "Tanks": 0, + "ColdRooms": 0, + "Machines": 0, + "Boilers": 0, + "SmokeAlarms": 0, + "Other": 0 + } + + for a in assets: + name = a['name'].lower() + if 'tank' in name or 'physical' in name: stats["Tanks"] += 1 + elif 'cold' in name: stats["ColdRooms"] += 1 + elif 'machine' in name or 'filling' in name: stats["Machines"] += 1 + elif 'boiler' in name: stats["Boilers"] += 1 + elif 'smoke' in name or 'alarm' in name: stats["SmokeAlarms"] += 1 + else: stats["Other"] += 1 + + return stats + + def _is_general_status_query(self, query: str) -> bool: + normalized = "".join(query.lower().split()) + for kw in ["systemstatus", "statusofall", "allassets", "alldevices", "completestatus", "statusreport"]: + if kw in normalized: + return True + return False + + def is_prefix_number_code(self, name: str) -> bool: + normalized = re.sub(r'[^a-zA-Z0-9]', '', name).lower() + return bool(re.match(r'^[a-zA-Z]+\d+$', normalized)) + + def is_asset_mentioned(self, asset_name: str, query: str) -> bool: + """ + Robust check if asset_name is mentioned in the query, + respecting word boundaries and avoiding substring overlap (e.g. "st1" inside "past10"). + """ + q = query.lower() + an = asset_name.lower() + + # 1. Exact or direct substring with word boundaries in original query + words = re.findall(r'[a-zA-Z]+|\d+', an) + if not words: + return False + + # Build a regex pattern that matches the words in order, allowing optional spaces/hyphens/underscores + pattern_parts = [] + for i, w in enumerate(words): + pattern_parts.append(re.escape(w)) + if i < len(words) - 1: + pattern_parts.append(r'\s*[-_]?\s*') + + pattern = r'\b' + "".join(pattern_parts) + r'\b' + + try: + if re.search(pattern, q): + return True + except Exception: + pass + + # 1b. Check prefix-stripped suffix matching (e.g., "ST1 Weather Station" matches when query has "weather station") + match = re.match(r'^([a-zA-Z]+\d+)\s+(.+)$', asset_name.strip()) + if match: + suffix = match.group(2) + if len(suffix) > 3: + if self.is_asset_mentioned(suffix, query): + return True + + # 2. Normalized fallback (only for names longer than 4 characters to avoid ST1/F1 false positives) + norm_q = self.normalize_name(q) + norm_an = self.normalize_name(an) + if len(norm_an) > 4 and not self.is_prefix_number_code(an) and norm_an in norm_q: + return True + + return False + + def find_mentioned_assets(self, query_name: str, user_id: str) -> List[Dict[str, Any]]: + self._load_user_assets(user_id) + allowed_assets = self._user_cache.get(user_id, []) + + is_general_status = self._is_general_status_query(query_name) + mentioned = [] + + for a in allowed_assets: + if is_general_status and a['name'].lower() == 'status': + continue + if self.is_asset_mentioned(a['name'], query_name): + mentioned.append(a) + + # If both a physical/non-Status asset and 'Status' are matched, discard 'Status' + if len(mentioned) >= 2: + has_physical = any(a['name'].lower() != 'status' for a in mentioned) + if has_physical: + mentioned = [a for a in mentioned if a['name'].lower() != 'status'] + + return mentioned + + def find_asset(self, query_name: str, user_id: str) -> Optional[Dict[str, Any]]: + """Finds an asset by name using robust matching, filtered by user permissions.""" + self._load_user_assets(user_id) + allowed_assets = self._user_cache.get(user_id, []) + + # --------------------------------------------------------------- + # PRE-RESOLUTION: "Tank N" -> "physicalNN" (zero-padded, 2 digits) + # This mirrors the same rule in agents/live/system_message.md so + # that both the Live path and the Report path resolve to the same + # physical asset regardless of which path processes the message. + # --------------------------------------------------------------- + # tank_alias_match = re.search(r'\btank\s*(\d+)\b', query_name, re.IGNORECASE) + # if tank_alias_match: + # tank_num = int(tank_alias_match.group(1)) + # physical_name = f'physical{tank_num:02d}' # e.g. physical11 + # for a in allowed_assets: + # if self.normalize_name(a['name']) == physical_name: + # logger.debug(f"Asset Registry: 'Tank {tank_num}' pre-resolved to '{a['name']}' ({a['id']})") + # return a + + # 1. Multi-tank comparison check + tank_matches = re.findall(r'\btank\s*(\d+)\b', query_name, re.IGNORECASE) + if len(tank_matches) >= 2: + matched_assets = [] + for t_num in tank_matches: + physical_name = f'physical{int(t_num):02d}' + for a in allowed_assets: + if self.normalize_name(a['name']) == self.normalize_name(physical_name): + matched_assets.append(a) + break + # Multiple allowed tanks + if len(matched_assets) >= 2: + parent_ids = {a.get('parent_id') for a in matched_assets if a.get('parent_id')} + if len(parent_ids) == 1: + parent_id = list(parent_ids)[0] + parent_asset = self.get_asset_by_id(parent_id, user_id) + if parent_asset: + logger.info(f"Asset Registry: Multi-tank comparison detected. Mapping to common parent '{parent_asset['name']}' ({parent_asset['id']})") + return parent_asset + + # 2. General Multi-asset comparison check (For Coldrooms, Machines, etc.) + mentioned = self.find_mentioned_assets(query_name, user_id) + if len(mentioned) >= 2: + parent_ids = {a.get('parent_id') for a in mentioned if a.get('parent_id')} + if len(parent_ids) == 1: + parent_id = list(parent_ids)[0] + parent_asset = self.get_asset_by_id(parent_id, user_id) + if parent_asset: + logger.info(f"Asset Registry: Multi-asset comparison detected. Mapping to common parent '{parent_asset['name']}' ({parent_asset['id']})") + return parent_asset + + # 2. Single tank fallback + tank_alias_match = re.search(r'\btank\s*(\d+)\b', query_name, re.IGNORECASE) + if tank_alias_match: + tank_num = int(tank_alias_match.group(1)) + physical_name = f'physical{tank_num:02d}' # e.g. physical11 + for a in allowed_assets: + if self.normalize_name(a['name']) == self.normalize_name(physical_name): + logger.debug(f"Asset Registry: 'Tank {tank_num}' pre-resolved to '{a['name']}' ({a['id']})") + return a + + + normalized_query = self.normalize_name(query_name) + if not normalized_query: + return None + + is_general_status = self._is_general_status_query(query_name) + 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)) + is_cold = 'cold' in normalized_query + is_boiler = 'boiler' in normalized_query + is_machine = any(k in normalized_query for k in ['machine', 'filling']) + + # Find target number in query (excluding time/limits) + num_matches = re.finditer(r'\b(\d+)\b', query_name) + target_num = None + for m in num_matches: + val = int(m.group(1)) + start, end = m.span() + surrounding = query_name[max(0, start - 15):min(len(query_name), end + 15)].lower() + if re.search( + r'\b\d+\s*(?:minute|min|hour|hr|day|week|month|read(?:ing|ngs?|ings?)|row|log|point|datapoint)s?\b', + surrounding + ): + continue + target_num = val + break + + if target_num is None: + num_match = re.search(r'(\d+)', normalized_query) + target_num = int(num_match.group(1)) if num_match else None + + candidate_scores = [] + for a in allowed_assets: + if is_general_status and a['name'].lower() == 'status': + continue + + name = a['name'] + db_name_lower = name.lower() + normalized_db_name = self.normalize_name(name) + if not normalized_db_name: + continue + + score = 0.0 + + # Rule 1: Exact Match (1.0) + if normalized_db_name == normalized_query: + score = 1.0 + + # Rule 2: Category + Number Match (0.98) + elif target_num is not None: + db_num_match = re.search(r'(\d+)', db_name_lower) + if db_num_match and int(db_num_match.group(1)) == target_num: + cleaned_db = re.sub(r'[\s_-]', '', db_name_lower) + 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)) + + category_matched = False + if is_tank and is_db_tank: + category_matched = True + elif is_cold and 'cold' in db_name_lower: + category_matched = True + elif is_boiler and 'boiler' in db_name_lower: + category_matched = True + elif is_machine and ('machine' in db_name_lower or 'filling' in db_name_lower): + category_matched = True + + if category_matched: + score = 0.98 + + # Rule 3: Explicit Mention (0.95) + if score == 0.0: + if self.is_asset_mentioned(name, query_name): + cleaned_db = re.sub(r'[\s_-]', '', db_name_lower) + 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)) + + category_conflict = False + if is_tank and not is_db_tank: + category_conflict = True + elif is_cold and 'cold' not in db_name_lower: + category_conflict = True + elif is_boiler and 'boiler' not in db_name_lower: + category_conflict = True + elif is_machine and not any(k in db_name_lower for k in ['machine', 'filling']): + category_conflict = True + + if not category_conflict: + score = 0.95 + + # Rule 4: General Category Match or Suffix removed match (0.70) + if score == 0.0: + is_general_category = (is_cold and 'cold' in db_name_lower) or \ + (is_tank and ('tank' in db_name_lower or 'physical' in db_name_lower)) or \ + (is_boiler and 'boiler' in db_name_lower) or \ + (is_machine and ('machine' in db_name_lower or 'filling' in db_name_lower)) + if is_general_category: + score = 0.70 + else: + for suffix in ['tank', 'asset', 'sensor', 'machine', 'boiler', 'coldroom']: + if normalized_query.endswith(suffix): + short = normalized_query[:-len(suffix)] + if normalized_db_name == short: + score = 0.70 + break + + # Rule 5: Generic Substring Match (0.60) + if score == 0.0: + if len(normalized_query) > 3 and normalized_db_name not in ['oil', 'water', 'status', 'temp', 'level']: + if normalized_query in normalized_db_name or normalized_db_name in normalized_query: + score = 0.60 + + if score > 0.0: + candidate_scores.append((a, score)) + + if not candidate_scores: + return None + + # Sort candidate scores descending by score value + candidate_scores.sort(key=lambda x: x[1], reverse=True) + best_match, best_score = candidate_scores[0] + + logger.info(f"Asset Registry: Best candidate '{best_match['name']}' with score {best_score}") + + if best_score < 0.95: + logger.info(f"Asset Registry: Best score {best_score} is below 95% threshold. Asking counter-question.") + return None + + # Check for ties on the highest score + ties = [item for item in candidate_scores if item[1] == best_score] + if len(ties) > 1: + logger.info(f"Asset Registry: Tie detected between {len(ties)} assets at score {best_score}. Asking counter-question.") + return None + + return best_match + + def get_asset_by_id(self, asset_id: str, user_id: str) -> Optional[Dict[str, Any]]: + self._load_user_assets(user_id) + for a in self._user_cache.get(user_id, []): + if a['id'] == asset_id: + return a + return None + + def list_all_assets(self, user_id: str) -> List[Dict[str, Any]]: + self._load_user_assets(user_id) + return self._user_cache.get(user_id, []) + +asset_registry = AssetRegistry() diff --git a/SuperAgent/app/services/email_service.py b/SuperAgent/app/services/email_service.py new file mode 100644 index 0000000000000000000000000000000000000000..59d72b6fe4bb98396d77a834218bffc76d2843a2 --- /dev/null +++ b/SuperAgent/app/services/email_service.py @@ -0,0 +1,76 @@ +import os +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +import logging + +logger = logging.getLogger(__name__) + +class EmailService: + def __init__(self): + self.smtp_host = os.getenv("SMTP_HOST") + self.smtp_port = os.getenv("SMTP_PORT") + self.smtp_user = os.getenv("SMTP_USER") + self.smtp_pass = os.getenv("SMTP_PASSWORD") + self.smtp_from = os.getenv("SMTP_FROM", "[EMAIL_ADDRESS]") + + def send_report(self, recipient: str, subject: str, body_html: str, attachment_path: str = None) -> bool: + if not recipient: + logger.warning("Email recipient is empty. Skipping email delivery.") + return False + + if not self.smtp_host or not self.smtp_user or not self.smtp_pass: + logger.warning("SMTP credentials are not configured in environment. Printing email to console (MOCK).") + print(f"\n================ MOCK EMAIL SENT ================") + print(f"To: {recipient}") + print(f"From: {self.smtp_from}") + print(f"Subject: {subject}") + print(f"Body: {body_html[:300]}...") + if attachment_path: + print(f"Attachment: {os.path.basename(attachment_path)}") + print(f"=================================================\n") + return True + + try: + # Create message container + msg = MIMEMultipart() + msg['From'] = self.smtp_from + msg['To'] = recipient + msg['Subject'] = subject + + # Record the MIME types of both parts - text/html + msg.attach(MIMEText(body_html, 'html')) + + # Attach PDF if provided + if attachment_path and os.path.exists(attachment_path): + filename = os.path.basename(attachment_path) + with open(attachment_path, "rb") as attachment: + part = MIMEBase("application", "octet-stream") + part.set_payload(attachment.read()) + encoders.encode_base64(part) + part.add_header( + "Content-Disposition", + f"attachment; filename={filename}", + ) + msg.attach(part) + + # Connect to SMTP server + port = int(self.smtp_port) if self.smtp_port else 587 + server = smtplib.SMTP(self.smtp_host, port) + server.starttls() + server.login(self.smtp_user, self.smtp_pass) + + # Send email + server.sendmail(self.smtp_from, recipient, msg.as_string()) + server.quit() + + logger.info(f"Email sent successfully to {recipient}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {recipient}: {str(e)}") + return False + +email_service = EmailService() diff --git a/SuperAgent/app/services/report_generator.py b/SuperAgent/app/services/report_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..ead6fc07e7ae558e182379cff500c821800d7186 --- /dev/null +++ b/SuperAgent/app/services/report_generator.py @@ -0,0 +1,13 @@ +import os +import sys + +# Dynamically append root folder of the project to sys.path to enable smooth resolution of agents package +root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +if root_dir not in sys.path: + sys.path.insert(0, root_dir) + +# Import the core logic from the newly decoupled report agent service +from agents.report.app.services.report_generator import report_generator_service, ReportGeneratorService + +# Maintain absolute backward compatibility for any consumers +__all__ = ["report_generator_service", "ReportGeneratorService"] diff --git a/SuperAgent/app/services/router.py b/SuperAgent/app/services/router.py new file mode 100644 index 0000000000000000000000000000000000000000..b310f0ffb4feace773f02f8ef2b7d3b96a5cd61d --- /dev/null +++ b/SuperAgent/app/services/router.py @@ -0,0 +1,881 @@ +import os +import re +import logging +import httpx +import datetime +from typing import Any, Literal, TypedDict, Optional, List, Dict +from langgraph.graph import END, START, StateGraph +from ..models.schemas import ChatRequest, ChatResponse +from groq import Groq +from .asset_registry import asset_registry + +logger = logging.getLogger(__name__) + +Intent = Literal["live", "analysis", "anomaly", "report"] + + +class SuperAgentState(TypedDict, total=False): + message: str # The actual question (after stripping the User_ID prefix) + raw_message: str # Original message exactly as received + session_id: str + user_id: str + user_authorized: bool # True once permission check passes + check_all: bool + force_retrain: bool + asset_id: str | None + asset_name: str | None + agent_calls: list[str] + intent: Intent + answer: str + analysis: Any + data: list[dict[str, Any]] + generated_sql: str | None + orchestration: list[str] + report_format: Literal["excel", "pdf"] + schema_name: str | None + + +class RouterService: + def __init__(self): + api_key = os.getenv("GROQ_API_KEY") + self.client = Groq(api_key=api_key) if api_key else None + self.model = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile") + + # Hugging Face uses port 7860, locally we default to 8000 + port = os.getenv("PORT", "8000") + + self.live_agent_url = os.getenv("LIVE_AGENT_URL", f"http://127.0.0.1:{port}/live") + self.analysis_agent_url = os.getenv("ANALYSIS_AGENT_URL", f"http://127.0.0.1:{port}/analysis") + self.anomaly_agent_url = os.getenv("ANOMALY_AGENT_URL", f"http://127.0.0.1:{port}/anomaly") + self.telemetry_agent_url = os.getenv("TELEMETRY_AGENT_URL", f"http://127.0.0.1:{port}/telemetry") + + # Bulletproof port rewrite for any loopback URLs + for attr in ["live_agent_url", "analysis_agent_url", "anomaly_agent_url", "telemetry_agent_url"]: + val = getattr(self, attr) + if "localhost" in val or "127.0.0.1" in val: + new_val = re.sub(r":\d+", f":{port}", val) + setattr(self, attr, new_val) + + self.graph = self._build_graph() + + def _build_graph(self): + graph = StateGraph(SuperAgentState) + + # --- Permission gate (runs first) --- + graph.add_node("check_permissions", self._check_permissions_node) + graph.add_node("classify_intent", self._classify_node) + graph.add_node("route_live", self._route_live_node) + graph.add_node("route_analysis", self._route_analysis_node) + graph.add_node("route_anomaly", self._route_anomaly_node) + graph.add_node("route_report_intent", self._route_report_intent_node) + graph.add_node("generate_excel", self._generate_excel_node) + graph.add_node("generate_pdf", self._generate_pdf_node) + + graph.add_edge(START, "check_permissions") + graph.add_conditional_edges( + "check_permissions", + self._route_after_permission_check, + { + "authorized": "classify_intent", + "denied": END, # Short-circuit — answer already set + }, + ) + graph.add_conditional_edges( + "classify_intent", + self._route_from_intent, + { + "live": "route_live", + "analysis": "route_analysis", + "anomaly": "route_anomaly", + "report": "route_report_intent", + }, + ) + graph.add_conditional_edges( + "route_report_intent", + self._route_from_report_format, + { + "excel": "generate_excel", + "pdf": "generate_pdf", + }, + ) + graph.add_edge("route_live", END) + graph.add_edge("route_analysis", END) + graph.add_edge("route_anomaly", END) + graph.add_edge("generate_excel", END) + graph.add_edge("generate_pdf", END) + return graph.compile() + + async def classify_intent(self, message: str) -> Intent: + deterministic_intent = self._classify_with_rules(message) + if deterministic_intent is not None: + return deterministic_intent + + if self.client is None: + return "live" + + prompt = f""" + Classify this telemetry question for routing. + + Return "report" when the user wants to generate, download, or create a report file, + especially when they mention downloading, exporting, or creating an excel, xlsx, + pdf, sheet, or document, or ask to "generate a report" for a specific asset. + + Return "analysis" when the user asks for historical analysis, trend analysis, + reports, charts, graphs, summaries over a time period, comparisons, + averages/min/max over a window, scheduled analysis, or any + date/range-based request. + + Return "anomaly" when the user asks for status, anomalies, faults, incidents, + spikes, drops, mentioned "foreman", or asks about coldrooms/tanks/smoke alarm system status, + what is happening, or how they are doing (e.g. fire safety, abnormal behavior). + + Return "live" when the user asks for current/latest/realtime status, + current value, online/offline state, latest sensor reading, current tank + level, live alerts, or what is happening right now. + + User Message: "{message}" + + Return ONLY the word "live", "analysis", "anomaly", or "report". + """ + + response = self.client.chat.completions.create( + messages=[ + {"role": "user", "content": prompt} + ], + model=self.model, + temperature=0, + max_tokens=10, + ) + + intent = response.choices[0].message.content.strip().lower() + if "anomaly" in intent: + return "anomaly" + if "analysis" in intent: + return "analysis" + if "report" in intent: + return "report" + return "live" + + # ------------------------------------------------------------------ + # Permission-check node + # ------------------------------------------------------------------ + + @staticmethod + def _parse_user_message(raw: str) -> tuple[str, str]: + """ + Splits a message of the form ``User_ID: question``. + + Returns (user_id_or_empty, question). + The separator is the *first* colon that follows a non-whitespace + token at the start of the string so that questions containing colons + are never accidentally split. + """ + # Match : + m = re.match(r'^(\S+?)\s*:\s*(.+)$', raw.strip(), re.DOTALL) + if m: + return m.group(1).strip(), m.group(2).strip() + return "", raw.strip() + + async def _check_permissions_node(self, state: SuperAgentState) -> SuperAgentState: + """ + First node in the graph. + + 1. Parses ``User_ID: question`` out of raw_message. + 2. Resolves the real user UUID (falls back to DEFAULT_USER_ID when + the parsed prefix looks like the legacy placeholder ``user_1``). + 3. Loads the user's allowed assets from the registry. + 4. Blocks the request (sets ``user_authorized=False``) when the user + has zero assets, indicating they don't exist in the system. + """ + raw = state.get("raw_message") or state.get("message", "") + default_uid = os.getenv("DEFAULT_USER_ID", "ad7103e4-9595-4000-a56a-8f650e3eddef") + + parsed_uid, question = self._parse_user_message(raw) + orch = state.get("orchestration", []) + + # Resolve real UUID + if parsed_uid and parsed_uid not in ("", "user_1"): + user_id = parsed_uid + orch.append(f"Gateway: Parsed User_ID='{user_id}' from message prefix") + else: + user_id = state.get("user_id") + if not parsed_uid: + orch.append("Gateway: No User_ID prefix found — using session/default user") + + # Check permissions via asset registry + allowed_assets = asset_registry.list_all_assets(user_id) + + if not allowed_assets: + logger.warning(f"Permission denied: user '{user_id}' has no authorized assets.") + orch.append(f"Gateway: � Permission denied — user '{user_id}' not found or has no assets.") + return { + "user_id": user_id, + "message": question, + "raw_message": raw, + "user_authorized": False, + "orchestration": orch, + "answer": ( + f"⛔ Access denied. The user **`{user_id}`** does not exist in the system " + "or has no assets assigned. Please verify your User ID and try again." + ), + "intent": "live", + } + + orch.append( + f"Gateway: ✅ User '{user_id}' authorized — {len(allowed_assets)} asset(s) available." + ) + return { + "user_id": user_id, + "message": question, + "raw_message": raw, + "user_authorized": True, + "orchestration": orch, + } + + def _route_after_permission_check(self, state: SuperAgentState) -> str: + return "authorized" if state.get("user_authorized", False) else "denied" + + # ------------------------------------------------------------------ + # Intent classification node + # ------------------------------------------------------------------ + + async def _classify_node(self, state: SuperAgentState) -> SuperAgentState: + intent = await self.classify_intent(state["message"]) + + is_all_query = any(kw in state["message"].lower() for kw in ["all assets", "show assets", "list assets", "summary"]) + + asset_id = None + asset_name = None + + # 1. First Priority: Try to resolve from the message query itself + if not is_all_query: + asset = asset_registry.find_asset(state["message"], state["user_id"]) + if asset: + asset_id = asset['id'] + asset_name = asset['name'] + + orch = state.get("orchestration", []) + orch.append(f"Gateway: Intent classified as '{intent}'") + + if is_all_query: + summary = asset_registry.get_summary(state["user_id"]) + orch.append(f"Gateway: 'All Assets' query detected. System Summary: {summary}") + elif asset_id: + orch.append(f"Gateway: Identified asset '{asset_name}' ({asset_id})") + + return { + "intent": intent or "live", + "asset_id": asset_id, + "asset_name": asset_name, + "orchestration": orch + } + + def _route_from_intent(self, state: SuperAgentState) -> Intent: + return state.get("intent", "live") + + async def _route_live_node(self, state: SuperAgentState) -> SuperAgentState: + orch = state.get("orchestration", []) + orch.append("Gateway -> Live Agent: Fetching real-time telemetry from database") + live_payload = { + "session_id": state.get("session_id") or "default", + "user_id": state.get("user_id"), + "message": state["message"], + "asset_id": state.get("asset_id"), + "asset_name": state.get("asset_name"), + "schema_name": state.get("schema_name"), + } + async with httpx.AsyncClient(timeout=300.0) as client: + response = await client.post(f"{self.telemetry_agent_url}/api/v1/chat/message", json=live_payload) + if response.status_code != 200: + orch.append("Live Agent: Failed to respond") + print(f"\n [GATEWAY ERROR] Live Route Failed: {response.text}\n") + return { + # "answer": f"Live Agent Error: {response.text}", + "answer": "The Gateway encountered an error communicating with the live telemetry service. Please try again.", + "intent": "live", + "orchestration": orch + } + data = response.json() + orch.append("Live Agent: Response received successfully") + + return { + "answer": data.get("answer", ""), + "intent": "live", + "data": data.get("rows", []), + "generated_sql": data.get("generated_sql"), + "orchestration": orch + } + + async def _route_analysis_node(self, state: SuperAgentState) -> SuperAgentState: + orch = state.get("orchestration", []) + orch.append("Gateway -> Analysis Agent: Initiating historical trend analysis") + analysis_payload = { + "message": state["message"], + "user_id": state.get("user_id"), + "asset_id": state.get("asset_id"), + "asset_name": state.get("asset_name"), + "schema_name": state.get("schema_name"), + } + async with httpx.AsyncClient(timeout=300.0) as client: + response = await client.post( + f"{self.telemetry_agent_url}/api/v1/chat/message", + json=analysis_payload, + ) + if response.status_code != 200: + orch.append("Analysis Agent: Failed to respond") + print(f"\n [GATEWAY ERROR] Analysis Route Failed: {response.text}\n") + return { + # "answer": f"Analysis Agent Error: {response.text}", + "answer": "The Gateway encountered an error communicating with the analysis service. Please try again.", + "intent": "analysis", + "orchestration": orch + } + data = response.json() + orch.append("Analysis Agent: Trend analysis complete") + + return { + "answer": data.get("answer", ""), + "intent": "analysis", + "analysis": data.get("analysis"), + "orchestration": orch + } + + async def _route_anomaly_node(self, state: SuperAgentState) -> SuperAgentState: + orch = state.get("orchestration", []) + orch.append("Gateway -> Anomaly Agent (The Foreman): Activating multi-agent industrial supervisor") + anomaly_payload = { + "message": state["message"], + "user_id": state.get("user_id"), + "asset_id": state.get("asset_id"), + "asset_name": state.get("asset_name"), + "schema_name": state.get("schema_name"), + } + async with httpx.AsyncClient(timeout=600.0) as client: + response = await client.post( + f"{self.anomaly_agent_url}/api/chat", + json=anomaly_payload, + ) + if response.status_code != 200: + orch.append("Anomaly Agent: Failed to respond") + return { + "answer": f"Anomaly Agent Error: {response.text}", + "intent": "anomaly", + "orchestration": orch + } + data = response.json() + orch.append("Anomaly Agent: Multi-agent reasoning cycle finished") + + return { + "answer": data.get("reply", ""), + "intent": "anomaly", + "data": data.get("data"), + "orchestration": orch + } + + def _classify_with_rules(self, message: str) -> Intent | None: + text = self._normalize_text(message) + + # Check for report generation intent + report_keywords = [ + "generate report", "download report", "get report", "create report", + "excel report", "pdf report", "xlsx report", "sheet report", "spreadsheet report", + "generate excel", "generate pdf", "download excel", "download pdf", + "save as pdf", "save as excel", "export to pdf", "export to excel", + "export pdf", "export excel", "make pdf", "make excel", "telemetry pdf", "telemetry excel", + "pdf file", "excel file", "xlsx file", "spreadsheet file", "sheet file", + "pdf", "excel", "xlsx", "spreadsheet", "csv" + ] + if any(kw in text for kw in report_keywords): + return "report" + + # Route coldrooms/tanks/smoke alarm system status, what is happening, + # and how they are doing to "anomaly" so that the anomaly agent is run. + has_specific_asset = any(term in text for term in [ + "coldroom", "cold room", "coldrooms", "cold rooms", + "tank", "tanks", + "smoke alarm", "smoke alarms", "smoke detector", "smoke detectors", + "smoke sensor", "smoke sensors", "fire alarm", "fire alarms" + ]) + if has_specific_asset: + is_status_query = "status" in text or "state" in text + is_happening_query = "happening" in text or "going on" in text or "what is with" in text + is_doing_query = ( + "doing" in text or + "how is" in text or + "how are" in text or + "how's" in text or + "is ok" in text or + "is okay" in text or + "are ok" in text or + "are okay" in text + ) + if is_status_query or is_happening_query or is_doing_query: + is_global = any(w in text for w in [ + "all", "every", "scan", "system", "saare", "incidents", + "tanks", "coldrooms", "zones", "alarms", "detectors", "sensors" + ]) + has_number = bool(re.search(r'\d+', text)) + if is_global or has_number: + return "anomaly" + + # Force "live" routing for global asset status summaries + global_status_keywords = ["systemstatus", "statusofall", "allassetsstatus", "alldevicesstatus", "completestatus", "statusreport"] + if any(kw in text.replace(" ", "") for kw in global_status_keywords): + return "live" + + analysis_phrases = ( + "analysis", + "analyze", + "analyse", + "trend", + "history", + "historical", + "report", + "graph", + "chart", + "compare", + "comparison", + "average", + "avg", + "minimum", + "maximum", + "variance", + "stability", + "stable", + "performance", + "efficiency", + "downtime", + "summary", + "summarize", + "scheduled analysis", + "schedule analysis", + "scheduled", + "forecast", + ) + anomaly_phrases = ( + "anomaly", + "anomalies", + "fault", + "faults", + "incident", + "incidents", + "spike", + "drop", + "suspicious", + "smoke", + "fire", + "foreman", + "abnormal", + "behavior", + "behaviour", + "faults", + "incidents", + "spikes", + "active alerts" + ) + live_phrases = ( + "right now", + "at the moment", + "currently", + "current", + "realtime", + "real time", + "real-time", + "live", + "latest", + "now", + "online", + "offline", + "status", + "state", + "value", + "reading", + "level", + "alert", + "alerts", + "show all", + "all assets", + "all devices", + "all attributes", + "end attributes", + "category", + "category assets", + "category devices", + ) + time_window_patterns = ( + r"\blast\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b", + r"\bpast\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b", + r"\bprevious\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks|month|months)\b", + r"\bfor\s+(today|yesterday|tomorrow)\b", + r"\b(today|yesterday)\b", + r"\bbetween\b.+\band\b", + r"\bfrom\b.+\bto\b", + r"\bsince\b", + r"\bover\s+the\s+(last|past|previous)\b", + 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", + r"\b\d{4}-\d{1,2}-\d{1,2}\b", + r"\b\d{1,2}/\d{1,2}/\d{2,4}\b", + ) + + has_analysis_phrase = any(phrase in text for phrase in analysis_phrases) + has_anomaly_phrase = any(phrase in text for phrase in anomaly_phrases) + has_live_phrase = any(phrase in text for phrase in live_phrases) + has_time_window = any(re.search(pattern, text) for pattern in time_window_patterns) + + if has_anomaly_phrase: + return "anomaly" + if has_analysis_phrase or has_time_window: + return "analysis" + if has_live_phrase: + return "live" + return None + + async def route_request(self, payload: ChatRequest) -> ChatResponse: + raw_uid = payload.user_id or "" + session_user_id = raw_uid if (raw_uid and raw_uid != "user_1") else None + + parsed_uid, question = self._parse_user_message(payload.message) + + if self._is_greeting(question): + # Intercept and return a polite, friendly greeting directly without database query + greeting_answer = await self._generate_greeting_response(question) + orch_steps = ["Gateway: Intercepted greeting message — responded directly without database query."] + print("\n🔄 System Orchestration:\n" + "\n".join([f"- {step}" for step in orch_steps])) + return ChatResponse( + answer=greeting_answer, + intent="live", + orchestration=None + ) + + # Centralized schema verification (Hardcoded to openremote to clean up JSON API) + schema_name = "openremote" + + final_state = await self.graph.ainvoke( + { + # Pass the raw message untouched so the permission node can parse the prefix + "raw_message": payload.message, + "message": payload.message, # will be replaced by the permission node + "asset_id": payload.asset_id, + "session_id": payload.session_id or "default", + "user_id": session_user_id, # may be overridden by parsed prefix + "schema_name": schema_name, + "orchestration": [] + } + ) + + answer = final_state.get("answer", "") + orch_steps = final_state.get("orchestration", []) + if orch_steps: + print("\n🔄 System Orchestration:\n" + "\n".join([f"- {step}" for step in orch_steps])) + + return ChatResponse( + answer=answer, + intent=final_state.get("intent", "live"), + analysis=final_state.get("analysis"), + data=final_state.get("data"), + generated_sql=final_state.get("generated_sql"), + orchestration=None + ) + + + def _is_greeting(self, message: str) -> bool: + # Normalize: convert to lower case, strip extra whitespace, and remove punctuation at start/end + text = message.strip().lower() + text_clean = re.sub(r"^[^\w]+|[^\w]+$", "", text) # remove non-alphanumeric at start/end + + # Split into words to analyze length and content + words = text_clean.split() + if not words: + return False + + # Common greeting words/phrases + greeting_words = { + "hi", 'hii','hiii', "hy", "hello", "hey", "hola", "yo", "greetings", "howdy", "helo", "hii", "heyy", + "salam", "assalamualaikum", "namaste", "wassup", "sup", + "ok", "okay", "thanks", "thankyou", "thank you", "nice", "good", "great", "fine", "cool", "perfect" + } + + greeting_phrases = [ + "good morning", "good afternoon", "good evening", "as salam alaykum", "whats up", "what's up", + "hi there", "hello there", "hey there", "how are you", "how r u", "how is it going", "how's it going", + "how are you today", "how are you doing", "hope you are doing well" + ] + + # Check if the entire message is one of the greeting phrases + if any(phrase in text_clean for phrase in greeting_phrases): + # Check for telemetry keywords to prevent false positives + if not any(kw in text_clean for kw in self._get_telemetry_keywords()): + return True + + # Check if the first word or the only word is a greeting word + # If it's a single-word message and it matches a greeting word, it's a greeting + if len(words) == 1 and words[0] in greeting_words: + return True + + # 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 + if len(words) <= 4 and words[0] in greeting_words: + if not any(kw in text_clean for kw in self._get_telemetry_keywords()): + return True + + return False + + def _get_telemetry_keywords(self) -> set[str]: + return { + "asset", "device", "tank", "coldroom", "cold room", "temperature", "temp", "oil", "level", "value", + "status", "online", "offline", "fault", "anomaly", "anomalies", "alarm", "smoke", "fire", "incident", "incidents", + "report", "graph", "chart", "analyze", "analysis", "trend", "history", "historical", "reading", "humidity", + "zone", "refinery", "foreman", "baseline", "sensor", "attribute", "attributes", "show all", "list", "query" + } + + async def _generate_greeting_response(self, greeting: str) -> str: + if self.client: + try: + prompt = f""" + You are the Multi-Agent Telemetry SuperAgent. The user has greeted you with: "{greeting}". + Respond to their greeting in an extremely polite, warm, and professional way. + Keep it brief (2-3 sentences), and offer to help them monitor their IoT assets, analyze telemetry, or check for anomalies. + Do not query any databases or mention any specific asset data. + """ + response = self.client.chat.completions.create( + messages=[{"role": "user", "content": prompt}], + model=self.model, + temperature=0.7, + max_tokens=150, + ) + return response.choices[0].message.content.strip() + except Exception as e: + logger.error(f"Error generating greeting response with LLM: {e}") + + # Fallback static response if LLM is unavailable or fails + return ( + "Hello! I am your Multi-Agent Telemetry SuperAgent. 🌟\n\n" + "I'm here to help you monitor and analyze your IoT assets (like coldrooms and tanks) in real-time, " + "generate historical trend reports, or scan for anomalies with the Foreman.\n\n" + "How can I assist you today? Feel free to ask me questions like:\n" + "- *'What is the current status of all my assets?'*\n" + "- *'Compare the performance of tank 1 and tank 2 for yesterday.'*\n" + "- *'Are there any fire or smoke incidents in zone 3?'*" + ) + + async def _route_report_intent_node(self, state: SuperAgentState) -> SuperAgentState: + from .report_generator import report_generator_service + orch = state.get("orchestration", []) + fmt = report_generator_service.parse_format(state["message"]) + orch.append(f"Report Agent: Identified format request as '{fmt.upper()}'") + return { + "report_format": fmt, + "orchestration": orch + } + + def _route_from_report_format(self, state: SuperAgentState) -> str: + return state.get("report_format", "pdf") + + async def _analyze_and_enrich_report(self, state: SuperAgentState) -> tuple[Optional[str], Optional[list[dict]], list[str]]: + """ + Extracts the core query, runs the appropriate underlying agent (live, analysis, anomaly) + to get actual analytical findings and telemetry rows, and returns: + (agent_answer, telemetry_rows, orchestration_steps) + """ + orch = state.get("orchestration", []) + original_msg = state["message"] + + # 1. Extract core query + core_query = self._extract_core_query(original_msg) + orch.append(f"Report Agent: Extracted core query for analysis: '{core_query}'") + + # 2. Classify intent of the core query + underlying_intent = await self.classify_intent(core_query) + if underlying_intent == "report": + # Fallback rules to avoid infinite loop + text = self._normalize_text(core_query) + if any(phrase in text for phrase in ["anomaly", "fault", "incident", "status", "doing"]): + underlying_intent = "anomaly" + elif any(phrase in text for phrase in ["trend", "history", "average", "avg", "last", "past"]): + underlying_intent = "analysis" + else: + underlying_intent = "live" + + orch.append(f"Report Agent: Core query classified as '{underlying_intent}' intent") + + agent_answer = None + telemetry_rows = None + + # 3. Call the appropriate underlying agent + if underlying_intent == "live": + orch.append("Report Agent -> Routing core query to Live Agent...") + live_payload = { + "session_id": state.get("session_id") or "default", + "user_id": state.get("user_id"), + "message": core_query, + "asset_id": state.get("asset_id"), + "asset_name": state.get("asset_name"), + "schema_name": state.get("schema_name"), + } + try: + async with httpx.AsyncClient(timeout=300.0) as client: + response = await client.post(f"{self.telemetry_agent_url}/api/v1/chat/message", json=live_payload) + if response.status_code == 200: + data = response.json() + agent_answer = data.get("answer", "") + telemetry_rows = data.get("rows", []) + orch.append("Report Agent: Successfully retrieved real-time analysis and data from Live Agent") + else: + orch.append(f"Report Agent: Live Agent responded with status {response.status_code}") + except Exception as e: + orch.append(f"Report Agent: Error calling Live Agent: {str(e)}") + + elif underlying_intent == "analysis": + orch.append("Report Agent -> Routing core query to Analysis Agent...") + analysis_payload = { + "message": core_query, + "user_id": state.get("user_id"), + "asset_id": state.get("asset_id"), + "asset_name": state.get("asset_name"), + "schema_name": state.get("schema_name"), + } + try: + async with httpx.AsyncClient(timeout=300.0) as client: + response = await client.post(f"{self.telemetry_agent_url}/api/v1/chat/message", json=analysis_payload) + if response.status_code == 200: + data = response.json() + agent_answer = data.get("answer", "") + # Try to extract data if present in analysis + analysis_data = data.get("analysis", {}) + if isinstance(analysis_data, dict): + series = analysis_data.get("trend_chart", {}).get("series", []) + if series and isinstance(series, list): + rows = [] + for s in series: + metric = s.get("label", "Telemetry") + for p in s.get("points", []): + rows.append({ + "timestamp": p.get("label", "N/A"), + "metric": metric, + "value": p.get("value", 0), + "unit": "Units", + "status": "NORMAL" + }) + if rows: + telemetry_rows = rows + orch.append("Report Agent: Successfully retrieved trend analysis from Analysis Agent") + else: + orch.append(f"Report Agent: Analysis Agent responded with status {response.status_code}") + except Exception as e: + orch.append(f"Report Agent: Error calling Analysis Agent: {str(e)}") + + elif underlying_intent == "anomaly": + orch.append("Report Agent -> Routing core query to Anomaly Agent...") + anomaly_payload = { + "message": core_query, + "user_id": state.get("user_id"), + "asset_id": state.get("asset_id"), + "asset_name": state.get("asset_name"), + "schema_name": state.get("schema_name"), + } + try: + async with httpx.AsyncClient(timeout=600.0) as client: + response = await client.post(f"{self.anomaly_agent_url}/api/chat", json=anomaly_payload) + if response.status_code == 200: + data = response.json() + agent_answer = data.get("reply", "") + anomaly_data = data.get("data", []) + if anomaly_data and isinstance(anomaly_data, list): + telemetry_rows = [] + for item in anomaly_data: + telemetry_rows.append({ + "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "metric": "Anomaly Status", + "value": item.get("value") or item.get("latest_mse", "N/A"), + "unit": "Status", + "status": item.get("status") or "CRITICAL" + }) + orch.append("Report Agent: Successfully retrieved anomaly diagnosis from Anomaly Agent") + else: + orch.append(f"Report Agent: Anomaly Agent responded with status {response.status_code}") + except Exception as e: + orch.append(f"Report Agent: Error calling Anomaly Agent: {str(e)}") + + return agent_answer, telemetry_rows, orch + + def _extract_core_query(self, message: str) -> str: + msg = message + patterns = [ + 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", + r"\b(?:pdf|excel|xlsx|spreadsheet|csv|sheet|file|document)\s+report\s*(?:for|of|on|about)?\b", + r"\b(?:pdf|excel|xlsx|spreadsheet|csv|sheet|file|document)\s*(?:please|plz)?\b", + r"\b(?:please|plz)\s*(?:send|give|generate|download)\b", + ] + for pattern in patterns: + msg = re.sub(pattern, "", msg, flags=re.IGNORECASE) + msg = re.sub(r"\s+", " ", msg).strip() + msg = re.sub(r"^[?,.:;\s]+|[?,.:;\s]+$", "", msg) + return msg if msg else message + + async def _generate_excel_node(self, state: SuperAgentState) -> SuperAgentState: + from .report_generator import report_generator_service + orch = state.get("orchestration", []) + + # Pre-analyze core query + agent_answer, telemetry_rows, orch = await self._analyze_and_enrich_report(state) + + orch.append("Report Agent -> Excel Generator: Compiling custom XLSX operational sheet") + res = report_generator_service.generate_report( + user_id=state.get("user_id"), + asset_query=state["message"], + format_type="excel", + custom_data=telemetry_rows, + executive_summary=agent_answer, + asset_id=state.get("asset_id") + ) + orch.append("Excel Generator: File compiled successfully and link created") + + final_answer = "" + if agent_answer: + clean_ans = agent_answer + if "**System Orchestration:**" in clean_ans: + clean_ans = clean_ans.split("**System Orchestration:**")[0].strip() + final_answer += f"### � Analytical Findings & Diagnosis\n{clean_ans}\n\n---\n\n" + final_answer += res["answer"] + + return { + "answer": final_answer, + "intent": "report", + "orchestration": orch + } + + async def _generate_pdf_node(self, state: SuperAgentState) -> SuperAgentState: + from .report_generator import report_generator_service + orch = state.get("orchestration", []) + + # Pre-analyze core query + agent_answer, telemetry_rows, orch = await self._analyze_and_enrich_report(state) + + orch.append("Report Agent -> PDF Generator: Generating stylized operational report document") + res = report_generator_service.generate_report( + user_id=state.get("user_id"), + asset_query=state["message"], + format_type="pdf", + custom_data=telemetry_rows, + executive_summary=agent_answer, + asset_id=state.get("asset_id") + ) + orch.append("PDF Generator: PDF generated successfully and link created") + + final_answer = "" + if agent_answer: + clean_ans = agent_answer + if "**System Orchestration:**" in clean_ans: + clean_ans = clean_ans.split("**System Orchestration:**")[0].strip() + final_answer += f"### � Analytical Findings & Diagnosis\n{clean_ans}\n\n---\n\n" + final_answer += res["answer"] + + return { + "answer": final_answer, + "intent": "report", + "orchestration": orch + } + + def _normalize_text(self, message: str) -> str: + return re.sub(r"\s+", " ", message.strip().lower()) + +router_service = RouterService() diff --git a/SuperAgent/main.py b/SuperAgent/main.py new file mode 100644 index 0000000000000000000000000000000000000000..f7e95f3f2ef16314fcf1af6ead5d1daf8c114a68 --- /dev/null +++ b/SuperAgent/main.py @@ -0,0 +1,970 @@ +import os +import sys +import re +import httpx +import logging +from pathlib import Path +from typing import Optional, List, Dict, Any + +# Mute verbose httpx and httpcore INFO logs to keep console clean +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) + +# Add project root and sub-agent directories to sys.path to resolve dynamic package namespaces (Trigger Reload) +root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if root_dir not in sys.path: + sys.path.insert(0, root_dir) + +sys.path.append(os.path.join(root_dir, "agents", "live")) +sys.path.append(os.path.join(root_dir, "agents", "analysis", "backend")) +sys.path.append(os.path.join(root_dir, "agents", "anomaly", "backend")) +sys.path.append(os.path.join(root_dir, "agents", "telemetry")) +sys.path.append(os.path.join(root_dir, "agents", "report")) + +from fastapi import FastAPI, HTTPException, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from dotenv import load_dotenv + +env_path = Path(__file__).parent.parent / ".env" +load_dotenv(dotenv_path=env_path, override=True) + +from app.models.schemas import ChatRequest, ChatResponse, SessionCreateRequest, SessionTitleUpdateRequest +from app.services.router import router_service + +from fastapi.responses import RedirectResponse, FileResponse +from fastapi.staticfiles import StaticFiles + +app = FastAPI(title="Visibility AI Multi-Agent Gateway", version="0.1.0") + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +def resolve_and_verify_supabase_url() -> str: + import socket + import re + import urllib.request + import json + import ssl + + supabase_db_url = os.getenv("SUPABASE_DB_URL") + if not supabase_db_url: + raise ValueError("CRITICAL ERROR: SUPABASE_DB_URL environment variable is missing! Chat history requires a dedicated database configuration.") + + # Clean pgbouncer parameter + if "pgbouncer=" in supabase_db_url: + supabase_db_url = re.sub(r'[?&]pgbouncer=[^&]+', '', supabase_db_url) + + # Parse project ref and password + project_ref = "qwtpmkinqatjylozropu" + supabase_url = os.getenv("SUPABASE_URL", "") + match = re.search(r'https://([^.]+)\.supabase\.co', supabase_url) + if match: + project_ref = match.group(1) + + password = "zaheerafzal" + pass_match = re.search(r'postgresql://[^:]+:([^@]+)@', supabase_db_url) + if pass_match: + password = pass_match.group(1) + + # Try importing driver + connect_fn = None + try: + import psycopg + connect_fn = psycopg.connect + except ImportError: + try: + import psycopg2 + connect_fn = psycopg2.connect + except ImportError: + pass + + if not connect_fn: + print("SUPABASE CONNECTION: No postgres driver (psycopg/psycopg2) available for pre-verification.") + return supabase_db_url + + # Helper function to resolve DNS over HTTPS (DoH) with SSL bypass + def doh_resolve(hostname: str) -> str: + ctx = ssl._create_unverified_context() + # Try Google DoH + try: + url = f"https://dns.google/resolve?name={hostname}&type=A" + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=3, context=ctx) as response: + data = json.loads(response.read().decode()) + if "Answer" in data: + for answer in data["Answer"]: + if answer.get("type") == 1: + return answer.get("data") + except Exception as e: + print(f"SUPABASE CONNECTION: Google DoH failed for {hostname}: {e}") + + # Try Cloudflare DoH + try: + url = f"https://cloudflare-dns.com/dns-query?name={hostname}&type=A" + req = urllib.request.Request(url, headers={"Accept": "application/dns-json"}) + with urllib.request.urlopen(req, timeout=3, context=ctx) as response: + data = json.loads(response.read().decode()) + if "Answer" in data: + for answer in data["Answer"]: + if answer.get("type") == 1: + return answer.get("data") + except Exception as e: + print(f"SUPABASE CONNECTION: Cloudflare DoH failed for {hostname}: {e}") + return None + + # 1. Try resolving using system DNS first + host_match = re.search(r'@([^:/]+)', supabase_db_url) + if host_match: + configured_host = host_match.group(1) + resolved_ip = None + try: + resolved_ip = socket.gethostbyname(configured_host) + except Exception: + # Fallback to DoH if system DNS fails + print(f"SUPABASE CONNECTION: System DNS failed to resolve '{configured_host}'. Trying DNS-over-HTTPS...") + resolved_ip = doh_resolve(configured_host) + + if resolved_ip: + test_url = supabase_db_url + if resolved_ip != configured_host: + test_url = supabase_db_url.replace(f"@{configured_host}", f"@{resolved_ip}") + try: + with connect_fn(test_url, connect_timeout=3) as conn: + print(f"SUPABASE CONNECTION: Connected successfully to host/IP: {resolved_ip}") + return test_url + except Exception as e: + print(f"SUPABASE CONNECTION: Connection to '{resolved_ip}' failed: {e}") + + # 2. Try direct connection format (db..supabase.co:5432) + direct_host = f"db.{project_ref}.supabase.co" + resolved_direct_ip = None + try: + resolved_direct_ip = socket.gethostbyname(direct_host) + except Exception: + print(f"SUPABASE CONNECTION: System DNS failed to resolve direct host '{direct_host}'. Trying DNS-over-HTTPS...") + resolved_direct_ip = doh_resolve(direct_host) + + # Hardcoded IP fallback for the specific project if all lookups fail + if not resolved_direct_ip and project_ref == "qwtpmkinqatjylozropu": + print(f"SUPABASE CONNECTION: All lookups failed. Using fallback IP for {project_ref}...") + resolved_direct_ip = "3.111.134.120" + + if resolved_direct_ip: + direct_url = f"postgresql://postgres:{password}@{resolved_direct_ip}:5432/postgres" + try: + with connect_fn(direct_url, connect_timeout=3) as conn: + print(f"SUPABASE CONNECTION: Connected successfully to direct host IP: {resolved_direct_ip}") + return direct_url + except Exception as e: + print(f"SUPABASE CONNECTION: Connection to direct host IP '{resolved_direct_ip}' failed: {e}") + + # If all fail, fallback to original configured URL so SQLAlchemy throws a clean error + print("SUPABASE CONNECTION: Auto-detection failed. Falling back to configured URL.") + return supabase_db_url + + +supabase_db_url = resolve_and_verify_supabase_url() +if supabase_db_url.startswith("postgresql://"): + supabase_db_url = supabase_db_url.replace("postgresql://", "postgresql+psycopg://", 1) + +SupabaseEngine = create_engine(supabase_db_url, pool_pre_ping=True) +SupabaseSessionLocal = sessionmaker(bind=SupabaseEngine) +print("SUPABASE CHAT DB: Configured dedicated connection pool successfully.") + +# Import sub-agents applications +from agents.live.app.main import app as live_app +from agents.analysis.backend.app.main import app as analysis_app +from agents.anomaly.backend.api_chat import app as anomaly_app +from agents.telemetry.main import app as telemetry_app +from agents.report.main import app as report_app + +# Mount sub-agents natively on their respective sub-paths +app.mount("/live", live_app) +app.mount("/analysis", analysis_app) +app.mount("/anomaly", anomaly_app) +app.mount("/telemetry", telemetry_app) +app.mount("/report", report_app) + +# Custom route to force download report files or preview in browser +@app.get("/reports/{filename}") +async def download_report(filename: str): + + filepath = os.path.join("scratch/reports", filename) + if not os.path.exists(filepath): + raise HTTPException(status_code=404, detail="File not found") + + if filename.endswith(".pdf"): + # Serve PDF inline for browser previewing in a new tab + return FileResponse( + path=filepath, + media_type="application/pdf", + headers={"Content-Disposition": f"inline; filename={filename}"} + ) + else: + # Serve spreadsheet as a forced direct download + media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" if filename.endswith(".xlsx") else "application/octet-stream" + return FileResponse( + path=filepath, + filename=filename, + media_type=media_type + ) + +# Serve report files statically (fallback) +os.makedirs("scratch/reports", exist_ok=True) +app.mount("/reports", StaticFiles(directory="scratch/reports"), name="reports") + + +DEFAULT_USER_ID = os.getenv("DEFAULT_USER_ID", "ad7103e4-9595-4000-a56a-8f650e3eddef") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.get("/", tags=["Root"]) +async def read_index(): + return { + "status": "online", + "service": "Multi-Agent Telemetry SuperAgent API", + "integrations": { + "live_agent": "Connected [OK]", + "analysis_agent": "Connected [OK]", + "anomaly_agent": "Connected [OK]", + "postgresql_db": "Connected [OK]" + }, + "api_endpoints_guide": { + "/health": "GET - Check server status.", + "/api/v1/chat/message": "POST - Unified AI chat endpoint (Live/Analysis/Anomaly).", + "/api/v1/assets": "GET - List authorized assets for a user.", + "/api/v1/schedules": "GET - List available automated report types.", + "/api/v1/schedules/run/{id}": "POST - Manually trigger a specific report.", + "/api/v1/anomalies": "GET - Real-time summary of all active incidents.", + "/docs": "GET - Interactive Swagger UI documentation." + }, + "message": "Backend is running independently. Connect your frontend to this URL." + } + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + +@app.get("/api/v1/assets") +async def list_assets(user_id: str = None): + from app.services.asset_registry import asset_registry + assets = asset_registry.list_all_assets(user_id=user_id or DEFAULT_USER_ID) + return assets + +@app.get("/api/v1/assets/{asset_id}") +async def get_asset(asset_id: str, user_id: str = None): + from app.services.asset_registry import asset_registry + asset = asset_registry.get_asset_by_id(asset_id, user_id=user_id or DEFAULT_USER_ID) + if not asset: + raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found or not authorized.") + return asset + +@app.get("/debug/logs") +async def get_logs(): + logs = {"cwd": os.getcwd()} + for agent in ["startup", "live_agent", "analysis_agent", "anomaly_agent"]: + log_path = f"/app/{agent}.log" + if os.path.exists(log_path): + with open(log_path, "r") as f: + logs[agent] = f.read()[-2000:] # Last 2000 chars + else: + # Fallback to relative path if /app/ doesn't exist + rel_path = f"../{agent}.log" + if os.path.exists(rel_path): + with open(rel_path, "r") as f: + logs[agent] = f.read()[-2000:] + else: + logs[agent] = f"Log file not found at {log_path} or {rel_path}" + return logs + +def maybe_auto_title_session(session_id: str, first_query: str): + import os + import re + from sqlalchemy import text + from app.services.asset_registry import asset_registry + + check_sql = "SELECT title FROM chat_sessions WHERE id = :id" + try: + with SupabaseSessionLocal() as session: + title = session.execute(text(check_sql), {"id": session_id}).scalar() + if not title or title != "New Chat": + return + + api_key = os.getenv("GROQ_API_KEY") + model = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile") + if not api_key: + print("AUTO-TITLE: No Groq API Key found. Skipping auto-title.") + return + + from groq import Groq + client = Groq(api_key=api_key) + + prompt = f""" + Create a short, professional topic title of 3-5 words summarizing the user's query. + Focus strictly on the core asset (e.g., Coldroom 1, Tank 3) or metric (e.g., Temperature, Humidity) mentioned. + Do NOT output generic diagnostic messages like "Unknown or Incomplete Query" or "Vague Query". + If the query is too short or hard to classify, summarize the query text directly (e.g., "Query about {first_query}"). + Do not add extra symbols, markdown, or quotes. + Query: "{first_query}" + Title: + """ + + response = client.chat.completions.create( + messages=[ + {"role": "user", "content": prompt} + ], + model=model, + temperature=0.3, + max_tokens=15, + ) + + new_title = response.choices[0].message.content.strip() + new_title = re.sub(r'^["\']|["\']$', '', new_title).strip() + + if len(new_title) > 60: + new_title = new_title[:57] + "..." + + if new_title: + update_sql = "UPDATE chat_sessions SET title = :title WHERE id = :id" + session.execute(text(update_sql), {"title": new_title, "id": session_id}) + session.commit() + print(f"AUTO-TITLE: Updated session {session_id} title to: '{new_title}'") + except Exception as e: + print(f"AUTO-TITLE ERROR: {e}") + +@app.post("/api/v1/chat/message", response_model=ChatResponse) +async def chat_message(payload: ChatRequest, background_tasks: BackgroundTasks) -> ChatResponse: + print(f"DEBUG: Received request: {payload}") + try: + response = await router_service.route_request(payload) + + session_id = payload.session_id + if session_id and session_id != "default": + parsed_uid, clean_message = router_service._parse_user_message(payload.message) + effective_user_id = payload.user_id if (payload.user_id and payload.user_id != "user_1") else DEFAULT_USER_ID + if parsed_uid and parsed_uid != "user_1": + effective_user_id = parsed_uid + + ensure_session_sql = """ + INSERT INTO chat_sessions (id, user_id, title, created_at, updated_at) + VALUES (:id, :user_id, :title, NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET updated_at = NOW(); + """ + + insert_message_sql = """ + INSERT INTO chat_messages (session_id, user_id, role, content, created_at) + VALUES (:session_id, :user_id, :role, :content, NOW()) + """ + + try: + with SupabaseSessionLocal() as db_session: + db_session.execute(text(ensure_session_sql), { + "id": session_id, + "user_id": effective_user_id, + "title": "New Chat" + }) + + db_session.execute(text(insert_message_sql), { + "session_id": session_id, + "user_id": effective_user_id, + "role": "user", + "content": clean_message + }) + + db_session.execute(text(insert_message_sql), { + "session_id": session_id, + "user_id": effective_user_id, + "role": "assistant", + "content": response.answer + }) + db_session.commit() + + background_tasks.add_task(maybe_auto_title_session, session_id, clean_message) + except Exception as e: + print(f"CHAT HISTORY LOGGING ERROR: {e}") + + return response + except Exception as exc: + import traceback + error_msg = f"{type(exc).__name__}: {str(exc)}" + tb = traceback.format_exc() + print(f"ERROR: {error_msg}\n{tb}") + raise HTTPException(status_code=500, detail=f"Internal error: {error_msg}\n{tb[:200]}") from exc + +@app.get("/api/v1/chat/sessions") +async def list_chat_sessions(user_id: Optional[str] = None): + effective_user_id = user_id if (user_id and user_id != "user_1") else DEFAULT_USER_ID + query = """ + SELECT id, title, created_at, updated_at + FROM chat_sessions + WHERE user_id = :user_id + ORDER BY updated_at DESC + """ + from sqlalchemy import text + try: + with SupabaseSessionLocal() as session: + rows = session.execute(text(query), {"user_id": effective_user_id}).mappings().all() + return [dict(row) for row in rows] + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch chat sessions: {str(e)}") + +@app.post("/api/v1/chat/sessions") +async def create_chat_session(payload: SessionCreateRequest): + import uuid + from sqlalchemy import text + from app.services.asset_registry import asset_registry + + user_id = payload.user_id if (payload.user_id and payload.user_id != "user_1") else DEFAULT_USER_ID + session_id = f"session_{uuid.uuid4().hex[:12]}" + title = "New Chat" + + query = """ + INSERT INTO chat_sessions (id, user_id, title, created_at, updated_at) + VALUES (:id, :user_id, :title, NOW(), NOW()) + RETURNING id, title, created_at, updated_at + """ + try: + with SupabaseSessionLocal() as session: + row = session.execute(text(query), { + "id": session_id, + "user_id": user_id, + "title": title + }).mappings().first() + session.commit() + return dict(row) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to create chat session: {str(e)}") + +@app.get("/api/v1/chat/sessions/{session_id}/messages") +async def get_session_messages(session_id: str): + from sqlalchemy import text + from app.services.asset_registry import asset_registry + + query = """ + SELECT role, content + FROM chat_messages + WHERE session_id = :session_id + ORDER BY created_at ASC, id ASC + """ + try: + with SupabaseSessionLocal() as session: + rows = session.execute(text(query), {"session_id": session_id}).mappings().all() + return [dict(row) for row in rows] + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch messages for session: {str(e)}") + +@app.put("/api/v1/chat/sessions/{session_id}") +async def update_session_title(session_id: str, payload: SessionTitleUpdateRequest): + from sqlalchemy import text + from app.services.asset_registry import asset_registry + + query = """ + UPDATE chat_sessions + SET title = :title, updated_at = NOW() + WHERE id = :session_id + """ + try: + with SupabaseSessionLocal() as session: + res = session.execute(text(query), {"title": payload.title, "session_id": session_id}) + session.commit() + if res.rowcount == 0: + raise HTTPException(status_code=404, detail="Session not found") + return {"success": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to update session title: {str(e)}") + +@app.delete("/api/v1/chat/sessions/{session_id}") +async def delete_chat_session(session_id: str): + from sqlalchemy import text + from app.services.asset_registry import asset_registry + + delete_messages_sql = "DELETE FROM chat_messages WHERE session_id = :session_id" + delete_session_sql = "DELETE FROM chat_sessions WHERE id = :session_id" + try: + with SupabaseSessionLocal() as session: + check_res = session.execute(text("SELECT 1 FROM chat_sessions WHERE id = :session_id"), {"session_id": session_id}).scalar() + if not check_res: + raise HTTPException(status_code=404, detail="Session not found") + + session.execute(text(delete_messages_sql), {"session_id": session_id}) + session.execute(text(delete_session_sql), {"session_id": session_id}) + session.commit() + return {"success": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete chat session: {str(e)}") + + + +@app.get("/api/v1/anomalies") +async def get_active_anomalies(user_id: str = None, schema_name: str = None): + # Centralized schema verification for anomalies endpoint + resolved_schema = "openremote" + if schema_name and schema_name.lower() != "openremote": + try: + from app.services.asset_registry import asset_registry + from sqlalchemy import text + with asset_registry.SessionLocal() as session: + res = session.execute(text( + "SELECT 1 FROM information_schema.tables WHERE table_schema = :schema AND table_name = 'asset' LIMIT 1" + ), {"schema": schema_name}) + if res.scalar() is not None: + resolved_schema = schema_name + except Exception: + resolved_schema = "openremote" + + port = os.getenv("PORT", "8000") + anomaly_agent_url = os.getenv("ANOMALY_AGENT_URL", f"http://127.0.0.1:{port}/anomaly") + if "localhost" in anomaly_agent_url or "127.0.0.1" in anomaly_agent_url: + anomaly_agent_url = re.sub(r":\d+", f":{port}", anomaly_agent_url) + async with httpx.AsyncClient(timeout=300.0) as client: + response = await client.get( + f"{anomaly_agent_url}/api/v1/anomalies", + params={ + "user_id": user_id or DEFAULT_USER_ID, + "schema_name": resolved_schema + } + ) + return response.json() + +@app.get("/api/v1/reports/daily") +async def get_daily_reports(): + import csv + import ast + import sys + csv.field_size_limit(2147483647) + from pathlib import Path + + reports = {"coldrooms": [], "tanks": [], "smoke_alarms": []} + base_dir = Path(__file__).parent.parent + cr_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "coldroom_reports.csv" + tank_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "tank_reports.csv" + smoke_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "smoke_reports.csv" + + def parse_csv(file_path, target_type): + if not file_path.exists(): return [] + with open(file_path, mode='r', encoding='utf-8') as f: + reader = csv.DictReader(f) + all_rows = list(reader) + if not all_rows: return [] + filtered_rows = [row for row in all_rows if row.get('report_type') == target_type] + if not filtered_rows: return [] + latest_time = max(row['report_timestamp'] for row in filtered_rows) + latest_rows = [row for row in filtered_rows if row['report_timestamp'] == latest_time] + for row in latest_rows: + if 'anomalies' in row: + try: row['anomalies'] = ast.literal_eval(row['anomalies']) + except: pass + return latest_rows + + reports["coldrooms"] = parse_csv(cr_path, "daily") + reports["tanks"] = parse_csv(tank_path, "daily") + reports["smoke_alarms"] = parse_csv(smoke_path, "daily") + return reports + +@app.get("/api/v1/reports/latest") +async def get_latest_rolling_reports(): + import csv + import ast + import sys + csv.field_size_limit(2147483647) + from pathlib import Path + + reports = {"coldrooms": [], "tanks": [], "smoke_alarms": []} + base_dir = Path(__file__).parent.parent + cr_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "coldroom_reports.csv" + tank_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "tank_reports.csv" + smoke_path = base_dir / "agents" / "anomaly" / "backend" / "data" / "reports" / "smoke_reports.csv" + + def parse_csv(file_path, target_type): + if not file_path.exists(): return [] + with open(file_path, mode='r', encoding='utf-8') as f: + reader = csv.DictReader(f) + all_rows = list(reader) + if not all_rows: return [] + filtered_rows = [row for row in all_rows if row.get('report_type') == target_type] + if not filtered_rows: return [] + latest_time = max(row['report_timestamp'] for row in filtered_rows) + latest_rows = [row for row in filtered_rows if row['report_timestamp'] == latest_time] + for row in latest_rows: + if 'anomalies' in row: + try: row['anomalies'] = ast.literal_eval(row['anomalies']) + except: pass + return latest_rows + + reports["coldrooms"] = parse_csv(cr_path, "rolling") + reports["tanks"] = parse_csv(tank_path, "rolling") + reports["smoke_alarms"] = parse_csv(smoke_path, "rolling") + return reports + +import uuid +import json +import datetime +import time +import threading +from pydantic import BaseModel +from typing import Optional + +class ManualReportRequest(BaseModel): + user_id: Optional[str] = None + asset_id: str + timeframe: str + email: Optional[str] = None + format: Optional[str] = "pdf" + report_type: Optional[str] = "analysis" + +class ScheduleSaveRequest(BaseModel): + user_id: Optional[str] = None + asset_id: str + frequency: str + schedule_time: str + weekly_day: Optional[str] = None + recipient_email: str + report_start_hour: int + report_end_hour: int + report_format: str = "pdf" # 'pdf' or 'excel' + report_type: Optional[str] = "analysis" # 'analysis', 'trend', or 'anomaly' + is_active: bool = True + +def get_supabase_client(): + supabase_url = os.getenv("SUPABASE_URL") + supabase_key = os.getenv("SUPABASE_SERVICE_KEY") + if not supabase_url or not supabase_key: + raise ValueError("SUPABASE_URL or SUPABASE_SERVICE_KEY environment variables are not set") + from supabase import create_client + return create_client(supabase_url, supabase_key) + +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"): + try: + from app.services.asset_registry import asset_registry + from agents.report.app.services.report_generator import report_generator_service + from app.services.email_service import email_service + + # 1. Resolve asset + asset = asset_registry.get_asset_by_id(asset_id, user_id=user_id) + asset_name = asset["name"] if asset else asset_id + + # Build specific custom query suffix representing hours range + today_date = datetime.datetime.now().strftime("%Y-%m-%d") + custom_query = f"{asset_name} from {today_date} {start_hour:02d}:00:00 to {today_date} {end_hour:02d}:00:00" + + print(f"SCHEDULER: Generating custom timeframe report ({report_format}) for {asset_name}: '{custom_query}'...") + res = report_generator_service.generate_report( + user_id=user_id, + asset_query=custom_query, + format_type=report_format, + asset_id=asset_id, + report_type=report_type + ) + + if res.get("success") and recipient_email: + filepath = res.get("filepath") + subject = f"Scheduled Operational Report: {asset_name}" + body_html = f""" + + +

Scheduled Operational Report

+

Attached is the custom timeframe report ({report_format.upper()}) for {asset_name}.

+

Data Timeframe: {start_hour:02d}:00 to {end_hour:02d}:00 (Local Time)

+ + + """ + email_service.send_report( + recipient=recipient_email, + subject=subject, + body_html=body_html, + attachment_path=filepath + ) + print(f"SCHEDULER: Automated report sent to {recipient_email}") + else: + print(f"SCHEDULER: Report generation failed or no email configured. Success: {res.get('success')}") + except Exception as e: + print(f"SCHEDULER ERROR: Failed to run automated report: {e}") + +def run_background_scheduler(): + print("Background report scheduler loop running (Supabase integration)...") + + # 1. Run an immediate check on server startup to verify database queries and process pending reports + try: + print("SCHEDULER: Running initial database schedule check on server startup...") + supabase = get_supabase_client() + response = supabase.table("report_schedule").select("*").eq("is_active", True).execute() + schedules = response.data or [] + + now = datetime.datetime.now() + today_str = now.strftime("%Y-%m-%d") + current_time_str = now.strftime("%H:%M") + current_day_name = now.strftime("%A") + + print(f"SCHEDULER: Found {len(schedules)} active schedules. Current local server time: {current_time_str} ({current_day_name})") + + for s in schedules: + frequency = s.get("frequency", "daily").lower() + schedule_time = s.get("schedule_time", "09:00") + weekly_day = s.get("weekly_day") + last_run = s.get("last_run") + + should_run = False + # Check if current time is past the schedule run time + if current_time_str >= schedule_time: + if frequency == "daily": + if last_run != today_str: + should_run = True + elif frequency == "weekly": + if weekly_day and weekly_day.lower() == current_day_name.lower(): + if last_run != today_str: + should_run = True + + print(f"SCHEDULER STARTUP CHECK -> Asset ID: {s.get('asset_id')}, Time: {schedule_time}, Frequency: {frequency}, Last Run: {last_run}, Match: {should_run}") + + if should_run: + asset_id = s.get("asset_id") + user_id = s.get("user_id") or "ad7103e4-9595-4000-a56a-8f650e3eddef" + recipient_email = s.get("recipient_email") + start_hour = s.get("report_start_hour", 9) + end_hour = s.get("report_end_hour", 15) + report_format = s.get("report_format", "pdf") + report_type = s.get("report_type", "analysis") + + print(f"SCHEDULER: Launching automated report thread for {recipient_email}...") + threading.Thread( + target=trigger_automated_report, + args=(user_id, asset_id, recipient_email, start_hour, end_hour, report_format, report_type) + ).start() + + # Log execution to Supabase to prevent duplicate execution today + supabase.table("report_schedule").update({"last_run": today_str}).eq("id", s["id"]).execute() + except Exception as startup_err: + print(f"SCHEDULER STARTUP ERROR: Failed to run initial startup check: {startup_err}") + + # 2. Main infinite polling loop (sleep first, then check) + while True: + try: + # Sleep for 30 minutes (1800 seconds) between database checks + time.sleep(1800) + + print("SCHEDULER: Performing periodic database schedule check...") + supabase = get_supabase_client() + + # Fetch active schedules from Supabase report_schedule table + response = supabase.table("report_schedule").select("*").eq("is_active", True).execute() + schedules = response.data or [] + + now = datetime.datetime.now() + today_str = now.strftime("%Y-%m-%d") + current_time_str = now.strftime("%H:%M") + current_day_name = now.strftime("%A") + + print(f"SCHEDULER: Periodic check running. Current local server time: {current_time_str}") + + for s in schedules: + frequency = s.get("frequency", "daily").lower() + schedule_time = s.get("schedule_time", "09:00") + weekly_day = s.get("weekly_day") + last_run = s.get("last_run") + + should_run = False + # If current time is equal to or past the scheduled time (HH:MM) + if current_time_str >= schedule_time: + if frequency == "daily": + if last_run != today_str: + should_run = True + elif frequency == "weekly": + if weekly_day and weekly_day.lower() == current_day_name.lower(): + if last_run != today_str: + should_run = True + + print(f"SCHEDULER PERIODIC CHECK -> Asset ID: {s.get('asset_id')}, Time: {schedule_time}, Match: {should_run}") + + if should_run: + asset_id = s.get("asset_id") + user_id = s.get("user_id") or "ad7103e4-9595-4000-a56a-8f650e3eddef" + recipient_email = s.get("recipient_email") + start_hour = s.get("report_start_hour", 9) + end_hour = s.get("report_end_hour", 15) + report_format = s.get("report_format", "pdf") + report_type = s.get("report_type", "analysis") + + print(f"SCHEDULER: Launching automated report thread for {recipient_email}...") + threading.Thread( + target=trigger_automated_report, + args=(user_id, asset_id, recipient_email, start_hour, end_hour, report_format, report_type) + ).start() + + # Log execution date to Supabase to prevent duplicate execution today + supabase.table("report_schedule").update({"last_run": today_str}).eq("id", s["id"]).execute() + + except Exception as e: + print(f"Error in background scheduler loop: {e}") + +def init_chat_history_tables(): + from sqlalchemy import text + from app.services.asset_registry import asset_registry + + # 1. Create chat_sessions metadata table + create_sessions_sql = """ + CREATE TABLE IF NOT EXISTS chat_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + """ + + # 2. Create index on user_id + create_index_sql = """ + CREATE INDEX IF NOT EXISTS idx_chat_sessions_user ON chat_sessions(user_id); + """ + + # 3. Create chat_messages table if not exists (fallback) + create_messages_sql = """ + CREATE TABLE IF NOT EXISTS chat_messages ( + id BIGSERIAL PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + """ + + # 4. Alter chat_messages to add user_id if not present + alter_messages_sql = """ + ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS user_id TEXT; + """ + + try: + with SupabaseEngine.begin() as conn: + conn.execute(text(create_sessions_sql)) + conn.execute(text(create_index_sql)) + conn.execute(text(create_messages_sql)) + conn.execute(text(alter_messages_sql)) + print("CHAT HISTORY SCHEMA: Initialized successfully.") + except Exception as e: + print(f"CHAT HISTORY SCHEMA ERROR: Failed to initialize: {e}") + +@app.on_event("startup") +async def start_scheduler_on_startup(): + # Initialize chat history database tables + init_chat_history_tables() + + try: + # Start scheduler loop if Supabase keys exist + get_supabase_client() + threading.Thread(target=run_background_scheduler, daemon=True).start() + except Exception as e: + print(f"SCHEDULER NOT STARTED: {e}") + +@app.post("/api/v1/analysis/schedule/manual") +async def manual_report_endpoint(payload: ManualReportRequest): + try: + from app.services.asset_registry import asset_registry + from agents.report.app.services.report_generator import report_generator_service + from app.services.email_service import email_service + + user_id = payload.user_id or DEFAULT_USER_ID + asset = asset_registry.get_asset_by_id(payload.asset_id, user_id=user_id) + if not asset: + raise HTTPException(status_code=404, detail="Asset not found") + + asset_name = asset["name"] + asset_query = f"{asset_name} {payload.timeframe}" + + res = report_generator_service.generate_report( + user_id=user_id, + asset_query=asset_query, + format_type=payload.format or "pdf", + asset_id=payload.asset_id, + report_type=payload.report_type or "analysis" + ) + + if not res.get("success"): + raise HTTPException(status_code=400, detail=res.get("answer", "Failed to generate report")) + + if payload.email: + filepath = res.get("filepath") + subject = f"Manual Telemetry Report: {asset_name}" + body_html = f""" + + +

Manual Telemetry Report

+

Hello,

+

You requested a manual report for {asset_name}.

+

Timeframe: {payload.timeframe.capitalize()}

+
+

Generated by Visbot Multi-Agent Gateway.

+ + + """ + email_service.send_report( + recipient=payload.email, + subject=subject, + body_html=body_html, + attachment_path=filepath + ) + + return { + "success": True, + "download_url": res.get("download_url"), + "filename": res.get("filename") + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/v1/analysis/schedule/save") +async def save_schedule_endpoint(payload: ScheduleSaveRequest): + try: + supabase = get_supabase_client() + user_id = payload.user_id or DEFAULT_USER_ID + + # Check if record exists for this asset_id + check_resp = supabase.table("report_schedule").select("id").eq("asset_id", payload.asset_id).execute() + + record_data = { + "user_id": user_id, + "asset_id": payload.asset_id, + "frequency": payload.frequency, + "schedule_time": payload.schedule_time, + "weekly_day": payload.weekly_day, + "recipient_email": payload.recipient_email, + "report_start_hour": payload.report_start_hour, + "report_end_hour": payload.report_end_hour, + "report_format": payload.report_format, + "report_type": payload.report_type, + "is_active": payload.is_active + } + + if check_resp.data: + existing_id = check_resp.data[0]["id"] + supabase.table("report_schedule").update(record_data).eq("id", existing_id).execute() + else: + supabase.table("report_schedule").insert(record_data).execute() + + return {"success": True, "message": "Schedule saved to Supabase successfully."} + except Exception as e: + import traceback + print(f"SAVE SCHEDULE ERROR: {e}\n{traceback.format_exc()}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/v1/analysis/schedule/{asset_id}") +async def get_schedule_endpoint(asset_id: str): + try: + supabase = get_supabase_client() + response = supabase.table("report_schedule").select("*").eq("asset_id", asset_id).execute() + if response.data: + return response.data[0] + return {"is_active": False} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/SuperAgent/scratch/debug_sql.txt b/SuperAgent/scratch/debug_sql.txt new file mode 100644 index 0000000000000000000000000000000000000000..9fbbaa5a2593b6b5d205f93c58513fcee705c250 --- /dev/null +++ b/SuperAgent/scratch/debug_sql.txt @@ -0,0 +1,18 @@ +WITH latest_attributes AS ( + SELECT DISTINCT ON (attribute_name) + attribute_name, + value, + timestamp + FROM openremote.asset_datapoint + WHERE entity_id = ( + SELECT id FROM openremote.asset + WHERE REPLACE(LOWER(name), ' ', '') = REPLACE(LOWER('Refinery'), ' ', '') + ORDER BY version DESC LIMIT 1 + ) + ORDER BY attribute_name, timestamp DESC +) +SELECT + (SELECT name FROM openremote.asset WHERE REPLACE(LOWER(name), ' ', '') = REPLACE(LOWER('Refinery'), ' ', '') ORDER BY version DESC LIMIT 1) AS asset_name, + MAX(timestamp) + INTERVAL '3 hours' AS local_time, + jsonb_object_agg(attribute_name, value) AS sensor_data +FROM latest_attributes \ No newline at end of file diff --git a/SuperAgent/scratch/raw_llm_output.txt b/SuperAgent/scratch/raw_llm_output.txt new file mode 100644 index 0000000000000000000000000000000000000000..05bf3f2ec31dca9c31739e3c9effe4a1c707172f --- /dev/null +++ b/SuperAgent/scratch/raw_llm_output.txt @@ -0,0 +1,31 @@ +Based on the system message and available context, we can determine that the user is asking for the current readings of the assets. + +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. + +We will use Scenario O: Custom Report / Multi-Asset to generate the SQL query. + +Here is the plan: + +1. Filter the assets using the `allowed_assets` list. +2. Use the filtered assets to generate the SQL query. +3. Use the `ROW_NUMBER()` function to get the latest reading for each asset. +4. Format the result as a Markdown table. + +Here is the SQL query: +```sql +WITH Latest AS ( + SELECT a.name AS asset_name, ad.timestamp + INTERVAL '3 hours' AS local_time, + (ad.value::jsonb - 'Timestamp' - 'timestamp') AS sensor_data + FROM openremote.asset a + JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id + WHERE a.name IN (SELECT value FROM unnest(string_to_array(allowed_assets, ',')) AS value) + AND ad.attribute_name ILIKE 'data' + ORDER BY ad.timestamp DESC + LIMIT 1 +) +SELECT asset_name, TO_CHAR(local_time, 'HH12:MI AM') AS time_label, sensor_data +FROM Latest; +``` +This query will return the current readings of the assets in the `allowed_assets` list. + +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. \ No newline at end of file diff --git a/SuperAgent/scratch/read_pdf.py b/SuperAgent/scratch/read_pdf.py new file mode 100644 index 0000000000000000000000000000000000000000..9234a2d68d4c68bf6b250ff79e27aa43e130ff9d --- /dev/null +++ b/SuperAgent/scratch/read_pdf.py @@ -0,0 +1,44 @@ +import os +import sys + +pdf_path = r"d:\Visibility Bots Multi Agent\super-agent\SuperAgent\scratch\reports\report_coldroom1_20260520_172140.pdf" + +if not os.path.exists(pdf_path): + print(f"Error: File not found at {pdf_path}") + sys.exit(1) + +print(f"File size: {os.path.getsize(pdf_path)} bytes") + +# Try to import typical PDF extraction libraries +try: + import pypdf + print("Using pypdf:") + reader = pypdf.PdfReader(pdf_path) + for i, page in enumerate(reader.pages): + print(f"--- Page {i+1} ---") + print(page.extract_text()) +except ImportError: + try: + import PyPDF2 + print("Using PyPDF2:") + reader = PyPDF2.PdfReader(pdf_path) + for i, page in enumerate(reader.pages): + print(f"--- Page {i+1} ---") + print(page.extract_text()) + except ImportError: + try: + import pdfplumber + print("Using pdfplumber:") + with pdfplumber.open(pdf_path) as pdf: + for i, page in enumerate(pdf.pages): + print(f"--- Page {i+1} ---") + print(page.extract_text()) + except ImportError: + print("No advanced PDF text extractor (pypdf, PyPDF2, pdfplumber) is installed.") + print("Reading raw PDF headers/metadata via basic binary inspection:") + with open(pdf_path, 'rb') as f: + head = f.read(1000) + print("Header:", head[:200]) + f.seek(-1000, 2) + tail = f.read(1000) + print("Tail contains PDF EOF:", b"%%EOF" in tail) diff --git a/SuperAgent/scratch/reports/report_1st_f_20260604_170502.pdf b/SuperAgent/scratch/reports/report_1st_f_20260604_170502.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bc27e3ed648135abbf1beb4d1df9f9c2739ab411 Binary files /dev/null and b/SuperAgent/scratch/reports/report_1st_f_20260604_170502.pdf differ diff --git a/SuperAgent/scratch/reports/report_cold_room_01_20260603_101356.pdf b/SuperAgent/scratch/reports/report_cold_room_01_20260603_101356.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c1e522c1da3b501a9aceee9e03a65d25b542af99 Binary files /dev/null and b/SuperAgent/scratch/reports/report_cold_room_01_20260603_101356.pdf differ diff --git a/SuperAgent/scratch/reports/report_cold_room_01_20260605_164130.pdf b/SuperAgent/scratch/reports/report_cold_room_01_20260605_164130.pdf new file mode 100644 index 0000000000000000000000000000000000000000..62032ac4a658cbec8221e954f8cb3b32caeaed9d Binary files /dev/null and b/SuperAgent/scratch/reports/report_cold_room_01_20260605_164130.pdf differ diff --git a/SuperAgent/scratch/reports/report_cold_room_02_20260601_111023.pdf b/SuperAgent/scratch/reports/report_cold_room_02_20260601_111023.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f3657926fc69071897461746d738c1eb4020be65 Binary files /dev/null and b/SuperAgent/scratch/reports/report_cold_room_02_20260601_111023.pdf differ diff --git a/SuperAgent/scratch/reports/report_cold_room_02_20260601_112724.pdf b/SuperAgent/scratch/reports/report_cold_room_02_20260601_112724.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0a553ef685fc84183d571472546cafd9293cf232 Binary files /dev/null and b/SuperAgent/scratch/reports/report_cold_room_02_20260601_112724.pdf differ diff --git a/SuperAgent/scratch/reports/report_cold_room_03_20260601_110725.pdf b/SuperAgent/scratch/reports/report_cold_room_03_20260601_110725.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5983de2af5985cea09e83dcce76774d436db701c Binary files /dev/null and b/SuperAgent/scratch/reports/report_cold_room_03_20260601_110725.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom10_20260520_151010.pdf b/SuperAgent/scratch/reports/report_coldroom10_20260520_151010.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c1190f25d73de931322e0ce7982b33939ca2fa9d Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom10_20260520_151010.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom10_20260520_151223.pdf b/SuperAgent/scratch/reports/report_coldroom10_20260520_151223.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1b3f6b9a847c24f8ade80dbf24dd422ae0e24c1e Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom10_20260520_151223.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom10_20260520_154908.pdf b/SuperAgent/scratch/reports/report_coldroom10_20260520_154908.pdf new file mode 100644 index 0000000000000000000000000000000000000000..52412d05531167e286dd7d6dd58aa805c572be75 Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom10_20260520_154908.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom10_20260520_155631.pdf b/SuperAgent/scratch/reports/report_coldroom10_20260520_155631.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fcbb27b737af3d07a4820406c3551fcaba5c5bc2 Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom10_20260520_155631.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260520_161047.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260520_161047.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3db5f31a91f190dfa88a8d15a0e15f1c374a2bbd Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260520_161047.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260520_161326.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260520_161326.pdf new file mode 100644 index 0000000000000000000000000000000000000000..38c99ea8d724c7b1a31462a420d58929184d5270 Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260520_161326.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260520_162115.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260520_162115.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5659772f58f573a0966ae9a394a5f27ec75fd68a Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260520_162115.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260520_164714.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260520_164714.pdf new file mode 100644 index 0000000000000000000000000000000000000000..dccdae7433cbcd2ba40c8717855ad8d399a5f9f0 Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260520_164714.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260520_171604.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260520_171604.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1089dd39b420be7d6791e68c430d8ad28ae32911 Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260520_171604.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260520_172140.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260520_172140.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a77d883855cd9a71b38f801a7307fb74a0783041 Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260520_172140.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260520_172737.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260520_172737.pdf new file mode 100644 index 0000000000000000000000000000000000000000..92993159ab66cdb695fbfd52af84eec17980d232 Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260520_172737.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260521_114641.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260521_114641.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3fc10cf8ab2758e9a5387989a0659bbf414aedd1 Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260521_114641.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260601_104530.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260601_104530.pdf new file mode 100644 index 0000000000000000000000000000000000000000..eb397962318aa18072b9dc5a7015cdcf3d32a584 Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260601_104530.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260601_105254.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260601_105254.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d94a72e2e669ddd9f97dbc3eb8f7d62f3c18dd82 Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260601_105254.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260609_151700.xlsx b/SuperAgent/scratch/reports/report_coldroom1_20260609_151700.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d49fe6b30185b3662e4ea835a6f186aaecddd13a Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260609_151700.xlsx differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260618_150027.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260618_150027.pdf new file mode 100644 index 0000000000000000000000000000000000000000..726b12a627a86f0e1dc31e8260b391a8774126ea Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260618_150027.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260622_113931.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260622_113931.pdf new file mode 100644 index 0000000000000000000000000000000000000000..264d0ef1f438910b986072c5571fb40e1bdb13bf Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260622_113931.pdf differ diff --git a/SuperAgent/scratch/reports/report_coldroom1_20260622_153634.pdf b/SuperAgent/scratch/reports/report_coldroom1_20260622_153634.pdf new file mode 100644 index 0000000000000000000000000000000000000000..aac160fc7da28fc25291e103c6df141bd0efcf5a Binary files /dev/null and b/SuperAgent/scratch/reports/report_coldroom1_20260622_153634.pdf differ diff --git a/SuperAgent/scratch/reports/report_physical05_20260604_171330.pdf b/SuperAgent/scratch/reports/report_physical05_20260604_171330.pdf new file mode 100644 index 0000000000000000000000000000000000000000..19a9ab8fae4ce2b7c535eaa5336a7dd5e8d7a317 Binary files /dev/null and b/SuperAgent/scratch/reports/report_physical05_20260604_171330.pdf differ diff --git a/SuperAgent/scratch/reports/report_physical06_20260604_170048.pdf b/SuperAgent/scratch/reports/report_physical06_20260604_170048.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f504aa4c8337bda97e27bbdf0d9f78caca951ddc Binary files /dev/null and b/SuperAgent/scratch/reports/report_physical06_20260604_170048.pdf differ diff --git a/SuperAgent/scratch/reports/report_physical_03_20260603_101528.xlsx b/SuperAgent/scratch/reports/report_physical_03_20260603_101528.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5772199f6b01b30bdbf10d7634888ec4c71fb36f Binary files /dev/null and b/SuperAgent/scratch/reports/report_physical_03_20260603_101528.xlsx differ diff --git a/SuperAgent/scratch/reports/report_physical_refinery_20260604_171051.pdf b/SuperAgent/scratch/reports/report_physical_refinery_20260604_171051.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7429ef0ba3ee02cd709451c1bf8547df3269c57f Binary files /dev/null and b/SuperAgent/scratch/reports/report_physical_refinery_20260604_171051.pdf differ diff --git a/SuperAgent/scratch/reports/report_st1_20260521_141351.pdf b/SuperAgent/scratch/reports/report_st1_20260521_141351.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7f17aa8a952dd6f7c066c2c0fe7f1c321896ad60 --- /dev/null +++ b/SuperAgent/scratch/reports/report_st1_20260521_141351.pdf @@ -0,0 +1,103 @@ +%PDF-1.3 +%éëñ¿ +1 0 obj +<< +/Count 1 +/Kids [3 0 R] +/MediaBox [0 0 595.28 841.89] +/Type /Pages +>> +endobj +2 0 obj +<< +/OpenAction [3 0 R /FitH null] +/PageLayout /OneColumn +/Pages 1 0 R +/Type /Catalog +>> +endobj +3 0 obj +<< +/Contents 4 0 R +/Parent 1 0 R +/Resources 8 0 R +/Type /Page +>> +endobj +4 0 obj +<< +/Filter /FlateDecode +/Length 1141 +>> +stream +xœ•˜[WÛFÇßùóHNŠØ»V~3FõøBl‘4=yQjá:õ…Ê29ôÓwv-cK+ÖÒfs[Äà÷ âI~áB â1J5.\ ²^&´ ž@Òc®”ö‡,…§‹›®ï(Pnn‹Ÿ Œ/þ¼È©‡2š(OSˆ§@ío6ƒË/ÑäfC4¼}œÄã¨ÛÇ�qØïG÷á°~€ø'ŠÀç�2}$,|啵ǭ2ñ´V—€Æñ@Pf u·ùz™ä麛MšÃ8}^gù›³½û¬Ò}_Ï—;#ÔgÒ`aTá"�-kd2A±‡ñè.ꇸ“»ÑxÐ�£ÑðÀNÕ³ãû ê †ÜnD‚ÏÞK� +÷ÇËï ˜”>Õ.Rêáî­‹,°äøƒ‹ ~á¢Ýý0Y¦�#ºüD”r|>h£:‰é‘Z…‹ðÛ¨íb·®f'yxª•“Œ0uEäËåÙÂiËUÂS¬×º<[g¯­Ù¶PŽVÓí&Ïæ †?]mÖY[Ò-´£[¸†èöÔáãR+8ÑÀ-*è?韋õKºÐôæëtöלuX�Ò&÷M„]4Û5—ŠÚÅÒÀër_ʧ¦‰H®=‰Í‡2å Û}njiìü– +»˜³zÃ?ÂÞc} aò8tÇß*˜È*iÁ îBZIÛ6±‹â¢™°Ò}Ì‹M”`ßI¦óÕlOë âdõP +ßM}ÿðfïP�â"�êpPÔð®uˆоªu +7 ;•:¦íkáÖùñÜøÂ:Á‡Áo0H^�Uû%5ÇH7êRýSªÔŒ¨§¼ZÍgf9Õ²ôe¾Þnö±¨Ù¨B‰©�0\Ãs©—•±M^’ù"ù±Hm”ó¿çxÉ„È;)ÝšY#‰8€R™­Ÿ¢I<G=™80ÃA�¿At?y³±«�‹™¿&Åw3Lj¦|U!Z,|‚ÚTÝcžÒ¶ølK#ÜÓ‚àý˜7é°É“åó!i†µëû{MúX“a×Ár¯ÔLßËñ@™Fá” +¿ŠJ¹OÛå|:Ï_ß$Ë¿UVÁs—’–*–FiLkÄ BøØ×œiäLoœ +[ÅÆÞÛâ˜0>oclxÝ­¢æ¾°A8S¼®Ïïá “ÎnxL7•ó!ùö´ñ”rÓÂc0 ZÓ"õ´ +[NZÍÆjh¹ÄKZgŠ7¦žˆ4sÂãÚÞÙÑõð +[.x-ŒÕÀs‰ðÎ?I5® A7-ì ’¶¦¥ji•¶œ´š�UÓrŠ—´ÎoJ5®¨968á m* -¼ú‘PÚrÂk6VÏ%^Â;Sü4ÕÅ#Ü´ðNÍZÓªŸ¥-'­fc5´\â%­3Åk^z쎈Lš7VKNíÑМåä.á²7ÞE·á06oU®à>]¥™}òãât‘.Ó<{…Éö9ͺ³t…Çþõ6O³£³°Äkaí!™¥@¯íÓ—ôÿ +g c +endstream +endobj +5 0 obj +<< +/BaseFont /Helvetica-Bold +/Encoding /WinAnsiEncoding +/Subtype /Type1 +/Type /Font +>> +endobj +6 0 obj +<< +/BaseFont /Helvetica-Oblique +/Encoding /WinAnsiEncoding +/Subtype /Type1 +/Type /Font +>> +endobj +7 0 obj +<< +/BaseFont /Helvetica +/Encoding /WinAnsiEncoding +/Subtype /Type1 +/Type /Font +>> +endobj +8 0 obj +<< +/Font <> +/ProcSet [/PDF /Text /ImageB /ImageC /ImageI] +>> +endobj +9 0 obj +<< +/CreationDate (D:20260521091351Z) +>> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000102 00000 n +0000000205 00000 n +0000000285 00000 n +0000001499 00000 n +0000001601 00000 n +0000001706 00000 n +0000001803 00000 n +0000001910 00000 n +trailer +<< +/Size 10 +/Root 2 0 R +/Info 9 0 R +/ID [<0C42A503D576EB2CE4ECB633A0D02A32><0C42A503D576EB2CE4ECB633A0D02A32>] +>> +startxref +1965 +%%EOF diff --git a/SuperAgent/scratch/reports/report_st1_20260521_141425.pdf b/SuperAgent/scratch/reports/report_st1_20260521_141425.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bedb641f95a00cd339fda2e1914cf2a005b0923a Binary files /dev/null and b/SuperAgent/scratch/reports/report_st1_20260521_141425.pdf differ diff --git a/SuperAgent/scratch/reports/report_st1_20260521_142409.pdf b/SuperAgent/scratch/reports/report_st1_20260521_142409.pdf new file mode 100644 index 0000000000000000000000000000000000000000..91f9dcca337e10504d235fce72f87e08b6751a5e Binary files /dev/null and b/SuperAgent/scratch/reports/report_st1_20260521_142409.pdf differ diff --git a/SuperAgent/scratch/reports/report_st1_20260521_143134.pdf b/SuperAgent/scratch/reports/report_st1_20260521_143134.pdf new file mode 100644 index 0000000000000000000000000000000000000000..40faad17fb9169faaf113da505e178da9ed58ad9 Binary files /dev/null and b/SuperAgent/scratch/reports/report_st1_20260521_143134.pdf differ diff --git a/SuperAgent/scratch/reports/report_st2_20260609_145704.xlsx b/SuperAgent/scratch/reports/report_st2_20260609_145704.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..671ec6c390ef9909a843391288882819f652b6a6 Binary files /dev/null and b/SuperAgent/scratch/reports/report_st2_20260609_145704.xlsx differ diff --git a/SuperAgent/scratch/reports/report_zone_1_20260601_100150.xlsx b/SuperAgent/scratch/reports/report_zone_1_20260601_100150.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..a059fbd5bf0d5be5d94307c88a65da242375d6b5 Binary files /dev/null and b/SuperAgent/scratch/reports/report_zone_1_20260601_100150.xlsx differ diff --git a/agents/analysis/.DS_Store b/agents/analysis/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a3a7e4aee966a112a2854f47ff0d6b6134342a86 Binary files /dev/null and b/agents/analysis/.DS_Store differ diff --git a/agents/analysis/.env.example b/agents/analysis/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..f41cd816fbfa2aca8ece9dc88c553eeac4baf2e3 --- /dev/null +++ b/agents/analysis/.env.example @@ -0,0 +1,7 @@ +GROQ_API_KEY= +GROQ_MODEL=openai/gpt-oss-120b +DATABASE_URL= +LOCAL_TIMEZONE=Asia/Karachi +LOCAL_TIME_OFFSET_HOURS=3 +OPENREMOTE_SCHEMA=openremote +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 diff --git a/agents/analysis/.gitignore b/agents/analysis/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..f8ac107719bbf9733af860932b917d0ba030c97e --- /dev/null +++ b/agents/analysis/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.next/ +node_modules/ +.env +dist/ +build/ +*.pyc diff --git a/agents/analysis/backend/app/__init__.py b/agents/analysis/backend/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/agents/analysis/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/agents/analysis/backend/app/__pycache__/__init__.cpython-313.pyc b/agents/analysis/backend/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c927a686097c38b6718ecb1e181634aff3845a4 Binary files /dev/null and b/agents/analysis/backend/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/__pycache__/main.cpython-313.pyc b/agents/analysis/backend/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d938345f5831065eba74bcfddd7ff7ccecbaf708 Binary files /dev/null and b/agents/analysis/backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/api/routes/__init__.py b/agents/analysis/backend/app/api/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/agents/analysis/backend/app/api/routes/__init__.py @@ -0,0 +1 @@ + diff --git a/agents/analysis/backend/app/api/routes/__pycache__/__init__.cpython-313.pyc b/agents/analysis/backend/app/api/routes/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e1c7f2f14b5b482856f936383e98c7e5dc00815b Binary files /dev/null and b/agents/analysis/backend/app/api/routes/__pycache__/__init__.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/api/routes/__pycache__/analysis.cpython-313.pyc b/agents/analysis/backend/app/api/routes/__pycache__/analysis.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8f97db6fa3da94af81f87ae88c1665314c671b77 Binary files /dev/null and b/agents/analysis/backend/app/api/routes/__pycache__/analysis.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/api/routes/__pycache__/assets.cpython-313.pyc b/agents/analysis/backend/app/api/routes/__pycache__/assets.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cba797f0549e32c9cfee2930574d976497e4624f Binary files /dev/null and b/agents/analysis/backend/app/api/routes/__pycache__/assets.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/api/routes/__pycache__/chat.cpython-313.pyc b/agents/analysis/backend/app/api/routes/__pycache__/chat.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a7945f17c700390b20ce1d3786f9cfd42ffabcf Binary files /dev/null and b/agents/analysis/backend/app/api/routes/__pycache__/chat.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/api/routes/__pycache__/schedules.cpython-313.pyc b/agents/analysis/backend/app/api/routes/__pycache__/schedules.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2bdf5ae0d1f5f3864abae0edfa1c7b2ffdd83c77 Binary files /dev/null and b/agents/analysis/backend/app/api/routes/__pycache__/schedules.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/api/routes/analysis.py b/agents/analysis/backend/app/api/routes/analysis.py new file mode 100644 index 0000000000000000000000000000000000000000..5211dc99f1a92f9476f3a9139dbe0c93e67dbe0e --- /dev/null +++ b/agents/analysis/backend/app/api/routes/analysis.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from agents.analysis.backend.app.models.schemas import AnalysisRequest, AnalysisResponse +from agents.analysis.backend.app.services.analysis_agent import analysis_agent_service + + +router = APIRouter(tags=["analysis"]) + + +@router.post("/analysis/run", response_model=AnalysisResponse) +async def run_analysis(payload: AnalysisRequest) -> AnalysisResponse: + return await analysis_agent_service.run_analysis(payload) diff --git a/agents/analysis/backend/app/api/routes/assets.py b/agents/analysis/backend/app/api/routes/assets.py new file mode 100644 index 0000000000000000000000000000000000000000..5908e13a6b2314cd8fbd72ee0bb715b8f82ad36d --- /dev/null +++ b/agents/analysis/backend/app/api/routes/assets.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, HTTPException + +from agents.analysis.backend.app.services.live_asset_registry import live_asset_registry + + +router = APIRouter(tags=["assets"]) + + +@router.get("/assets") +async def list_assets(user_id: str = "user_1") -> list[dict]: + return [asset.model_dump() for asset in live_asset_registry.list_assets(user_id=user_id)] + + +@router.get("/assets/{asset_id}") +async def get_asset(asset_id: str, user_id: str = "user_1") -> dict: + asset = live_asset_registry.get_asset(asset_id, user_id=user_id) + if asset is None: + raise HTTPException(status_code=404, detail=f"Unknown asset: {asset_id}") + return asset.model_dump() diff --git a/agents/analysis/backend/app/api/routes/chat.py b/agents/analysis/backend/app/api/routes/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..2fd62d661ee804608248e6c79428f5ec1c2a1ebb --- /dev/null +++ b/agents/analysis/backend/app/api/routes/chat.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from agents.analysis.backend.app.models.schemas import ChatRequest, ChatResponse +from agents.analysis.backend.app.services.analysis_agent import analysis_agent_service + + +router = APIRouter(tags=["chat"]) + + +@router.post("/chat/message", response_model=ChatResponse) +async def chat_message(payload: ChatRequest) -> ChatResponse: + return await analysis_agent_service.run_chat(payload) diff --git a/agents/analysis/backend/app/api/routes/schedules.py b/agents/analysis/backend/app/api/routes/schedules.py new file mode 100644 index 0000000000000000000000000000000000000000..96d911e06ded50c373a4b02bc72ee549f59fa5b3 --- /dev/null +++ b/agents/analysis/backend/app/api/routes/schedules.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter, HTTPException +from typing import List, Optional +from pydantic import BaseModel + +from agents.analysis.backend.app.models.schemas import ChatResponse +from agents.analysis.backend.app.services.scheduling_agent import n8n_fast_path_service + +router = APIRouter(tags=["schedules"]) + +class ScheduleInfo(BaseModel): + id: str + name: str + description: str + triggers: List[str] + +@router.get("/schedules", response_model=List[ScheduleInfo]) +async def list_schedules(): + """List all available scheduled report types.""" + return [ + ScheduleInfo( + id="th_monitoring", + name="T & H Monitoring Report", + description="Generates a comprehensive Temperature and Humidity report for all coldrooms and labs.", + triggers=["Scheduled T&H Monitoring", "T&H Monitoring Schedule"] + ), + ScheduleInfo( + id="filling_machines", + name="Filling Machines Production Report", + description="Generates a production summary for all filling machines across shifts.", + triggers=["Schedule Filling Machines", "Scheduled Filling Machines"] + ) + ] + +@router.post("/schedules/run/{schedule_id}", response_model=ChatResponse) +async def run_schedule(schedule_id: str, offset: int = 0, span_days: int = 1): + """Manually trigger a scheduled report by its ID.""" + if schedule_id == "th_monitoring": + return n8n_fast_path_service._handle_th(offset=offset, span_days=span_days) + elif schedule_id == "filling_machines": + return n8n_fast_path_service._handle_filling(offset=offset, span_days=span_days) + else: + raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' not found.") diff --git a/agents/analysis/backend/app/core/__pycache__/config.cpython-313.pyc b/agents/analysis/backend/app/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..57dc6e3b8ebe151f961a27f0818632c36530ef69 Binary files /dev/null and b/agents/analysis/backend/app/core/__pycache__/config.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/core/config.py b/agents/analysis/backend/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..8b8eb9613ed999703aad8acf338013f511643906 --- /dev/null +++ b/agents/analysis/backend/app/core/config.py @@ -0,0 +1,28 @@ +from pathlib import Path +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +ROOT_ENV_FILE = Path(__file__).resolve().parents[5] / ".env" + + +class Settings(BaseSettings): + app_name: str = "VisBot Analysis" + groq_api_key: str = "" + groq_model: str = "lgpt-oss-120b" + database_url: str = "sqlite+pysqlite:///:memory:" + local_timezone: str = "Asia/Karachi" + local_time_offset_hours: int = 3 + openremote_schema: str = "openremote" + + model_config = SettingsConfigDict( + env_file=(str(ROOT_ENV_FILE), ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/agents/analysis/backend/app/main.py b/agents/analysis/backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..2bc278da42b0f243805e085e550112756187072a --- /dev/null +++ b/agents/analysis/backend/app/main.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from agents.analysis.backend.app.api.routes.analysis import router as analysis_router +from agents.analysis.backend.app.api.routes.assets import router as assets_router +from agents.analysis.backend.app.api.routes.chat import router as chat_router +from agents.analysis.backend.app.api.routes.schedules import router as schedules_router + + +app = FastAPI( + title="VisBot Analysis", + version="0.1.0", + description="Intelligent asset analysis agent powered by FastAPI, LangChain, and Groq.", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +app.include_router(assets_router, prefix="/api/v1") +app.include_router(chat_router, prefix="/api/v1") +app.include_router(analysis_router, prefix="/api/v1") +app.include_router(schedules_router, prefix="/api/v1") diff --git a/agents/analysis/backend/app/models/__init__.py b/agents/analysis/backend/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/agents/analysis/backend/app/models/__init__.py @@ -0,0 +1 @@ + diff --git a/agents/analysis/backend/app/models/__pycache__/__init__.cpython-313.pyc b/agents/analysis/backend/app/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..276bb621c8e00ae815929f8be6db13f1ede22aad Binary files /dev/null and b/agents/analysis/backend/app/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/models/__pycache__/schemas.cpython-313.pyc b/agents/analysis/backend/app/models/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0f20d30c5a04694bb9e9a0ff6b1324cdaf1713a2 Binary files /dev/null and b/agents/analysis/backend/app/models/__pycache__/schemas.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/models/schemas.py b/agents/analysis/backend/app/models/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..9cb2e53729553a023586cd2663bcc4f73820fe44 --- /dev/null +++ b/agents/analysis/backend/app/models/schemas.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal +from uuid import uuid4 + +from pydantic import BaseModel, Field + + +AnalysisScope = Literal["live", "historical"] +WindowUnit = Literal["minutes", "hours", "days", "weeks", "months"] + + +class TimeWindow(BaseModel): + scope: AnalysisScope = "live" + value: int = 1 + unit: WindowUnit = "hours" + label: str = "last 1 hour" + start_at: str | None = None + end_at: str | None = None + + +class AssetProfile(BaseModel): + asset_id: str + name: str + asset_type: str + description: str + db_asset_id: str = "" + parent_name: str = "" + child_count: int = 0 + is_device: bool = False + attribute_keys: list[str] = Field(default_factory=list) + has_live_data: bool = False + data_keys: list[str] = Field(default_factory=list) + supported_analyses: list[str] = Field(default_factory=list) + metrics: list[str] = Field(default_factory=list) + source_table: str = "" + analysis_instructions: str = "" + sql_notes: str = "" + lookup_names: list[str] = Field(default_factory=list) + temperature_keys: list[str] = Field(default_factory=list) + humidity_keys: list[str] = Field(default_factory=list) + level_keys: list[str] = Field(default_factory=list) + production_keys: list[str] = Field(default_factory=list) + nominal_range: dict[str, float] = Field(default_factory=dict) + + +class AnalysisPlan(BaseModel): + analysis_name: str + reasoning: str + time_window: TimeWindow + query_started_at: str = "" + query_ended_at: str = "" + query_window_label: str = "" + planner_prompt: str = "" + sql_prompt: str = "" + analyst_prompt: str = "" + sql_query: str + chart_hints: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + total_tokens: int = 0 + llm_raw_response: str = "" + + +class AnalysisReport(BaseModel): + report_id: str = Field(default_factory=lambda: str(uuid4())) + title: str + summary: str + markdown: str + html: str + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class AnalysisRequest(BaseModel): + asset_id: str | None = None + question: str + analysis_name: str | None = None + time_window: TimeWindow | None = None + schema_name: str | None = None + + + +class TrendPoint(BaseModel): + label: str + value: float + + +class TrendSeries(BaseModel): + key: str + label: str + color: str + points: list[TrendPoint] = Field(default_factory=list) + + +class TrendChart(BaseModel): + title: str + chart_type: Literal["line", "bar"] + x_label: str + y_label: str + summary: str = "" + series: list[TrendSeries] = Field(default_factory=list) + + +class AnalysisResponse(BaseModel): + asset: AssetProfile + plan: AnalysisPlan + report: AnalysisReport + trend_chart: TrendChart | None = None + trend_charts: list[TrendChart] = Field(default_factory=list) + rows: list[dict] = Field(default_factory=list) + + +class ChatRequest(BaseModel): + message: str + user_id: str = "user_1" + asset_id: str | None = None + schema_name: str | None = None + + + +class ChatResponse(BaseModel): + answer: str + analysis: AnalysisResponse diff --git a/agents/analysis/backend/app/prompts/analysis_planner.txt b/agents/analysis/backend/app/prompts/analysis_planner.txt new file mode 100644 index 0000000000000000000000000000000000000000..70accca5727e93a8d883e9bd20c9f2b865722a89 --- /dev/null +++ b/agents/analysis/backend/app/prompts/analysis_planner.txt @@ -0,0 +1,24 @@ +You are VisBot Analysis, an industrial asset analysis planner. + +Your job: +- understand the user's asset analysis request +- use the asset selected from the user's chat message +- use the backend-provided asset analysis instructions +- select the best analysis type +- infer the time window +- produce a safe SQL query plan + +Rules: +- prefer deterministic analysis types from the asset profile +- never invent unavailable metrics +- keep SQL read-only +- use the asset description to choose the correct analysis style +- explain the result in simple user-friendly language +- if the user asks for live data, prioritize the latest records +- if the user asks for historical analysis, use the requested time window + +Return concise planning text that explains: +1. chosen analysis +2. why it matches the asset +3. what time window should be used +4. what metrics matter most diff --git a/agents/analysis/backend/app/prompts/request_planner.txt b/agents/analysis/backend/app/prompts/request_planner.txt new file mode 100644 index 0000000000000000000000000000000000000000..d91b41b412e5832d2a1d721cea2e45c1acbd3b98 --- /dev/null +++ b/agents/analysis/backend/app/prompts/request_planner.txt @@ -0,0 +1,37 @@ +You are VisBot Analysis. + +Your job is to read the user's message and produce a structured asset-analysis plan. + +You must: +- detect the exact asset from the provided asset catalog +- choose the most suitable analysis type +- infer the time window from the user's wording +- prefer historical analysis styles when the user asks for trends, patterns, previous windows, comparisons, changes, drops, rises, stability, or insights over time +- keep the output strictly in JSON + +Rules: +- do not invent assets that are not in the catalog +- the provided asset catalog is a compact shortlist, so use the strongest name, alias, parent, and telemetry-key clues available +- prefer exact asset name matches while ignoring spaces and case +- pay attention to parent_name, child_count, has_live_data, and data_keys +- if the matched item looks like a parent/group asset without direct data, you may still choose it if the user is asking about that system or area +- use the asset description and analysis instructions to decide the analysis style +- if the asset is unclear, return asset_id as an empty string and explain the issue briefly +- if the user asks for "latest", treat it as a live or recent window +- if the user asks for history, trend, previous hour/day/week/month, comparison, movement, or insight, choose a historical window and an analysis type that benefits from multiple returned rows +- if the user asks for analysis but does not mention a time, date, range, or duration, use a historical window of the last 3 hours from the request time +- for cold rooms under T & H Monitoring, prefer `consumption_analysis` for the requested time window even if the user simply asks for analysis or consumption +- keep labels human-readable + +Return JSON with this exact shape: +{{ + "asset_id": "string", + "analysis_name": "string", + "time_window": {{ + "scope": "live or historical", + "value": 1, + "unit": "minutes|hours|days|weeks|months", + "label": "string" + }}, + "reasoning": "short string" +}} diff --git a/agents/analysis/backend/app/prompts/result_analyst.txt b/agents/analysis/backend/app/prompts/result_analyst.txt new file mode 100644 index 0000000000000000000000000000000000000000..d9d922d65c3b86fa32b6e0b21d279995f4ea2846 --- /dev/null +++ b/agents/analysis/backend/app/prompts/result_analyst.txt @@ -0,0 +1,34 @@ +You are VisBot Analysis. + +You are given: +- the user's original request +- the matched asset and its analysis instructions +- the SQL query that fetched data +- deterministic findings +- the returned database rows + +Write a clean end-user analysis in simple language. + +Rules: +- be analytical, not generic +- use the actual returned data +- highlight the most important findings first +- mention problems clearly if thresholds are crossed or data is stale +- avoid raw JSON formatting in the main answer +- keep the answer well structured and readable +- keep the answer concise and precise (prefer 5-10 short lines total) +- do not output empty bullet markers, standalone `*`, or markdown separators like `---` +- prefer short headings, clean paragraphs, and real lists only when each bullet has content +- if the selected asset is a parent/group with multiple child assets, summarize all child assets (not just one) and call out only the most critical outliers +- when the request is historical, explicitly describe trend direction, range, highs, lows, movement, and stability using the returned rows +- provide real insights from the historical series instead of restating a single latest point +- for tanks, present the reported metric as percentage filled (e.g. 85%) whenever possible +- for tanks, use `percentage_filled = ((MaxFt - TankOilLevelInFeet001) / MaxFt) * 100` whenever MaxFt is available +- for tanks, do not explain or mention how you calculated the level or percentage, just report the final fill percentage and current depth +- if deterministic findings confirm that MaxFt was found, prioritize the percentage filled in your summary +- for temperature and humidity (T&H) monitoring, always report how much time the system was on or consuming power within the "6am-6am" day +- for all day-based calculations, a 24-hour day is strictly considered from 6:00 AM to 6:00 AM the following day +- for temperature monitoring, when the temperature starts to increase and goes above 20, consider the subsequent temperature dip (after it started to rise) as the indicator that the system turned off at that time +- for temperature compliance, use only thresholds explicitly provided in the asset context or deterministic findings +- do not use generic or assumed industry ranges (for example "usually 2-8C") unless the user explicitly asks for benchmark ranges +- finish with one useful suggested next step diff --git a/agents/analysis/backend/app/prompts/sql_generator.txt b/agents/analysis/backend/app/prompts/sql_generator.txt new file mode 100644 index 0000000000000000000000000000000000000000..093c6709063139f2768ee096734e75cf1ff24c87 --- /dev/null +++ b/agents/analysis/backend/app/prompts/sql_generator.txt @@ -0,0 +1,46 @@ +You are VisBot Analysis. + +Generate exactly one read-only PostgreSQL query for OpenRemote. + +Database rules: +- all tables are under the openremote schema +- the main telemetry tables are openremote.asset and openremote.asset_datapoint +- join them using `ad.entity_id = a.id` +- sensor telemetry is usually stored in asset_datapoint.value JSON +- many telemetry rows use `attribute_name ILIKE 'data'`, but not all +- multiple attributes can contain JSON sensor payloads +- unless the user explicitly asks for a specific attribute, inspect all relevant JSON-carrying attributes that may hold telemetry for the chosen asset or group +- history is stored in asset datapoints, so historical queries should read from those datapoints directly +- do not use `ad.asset_id`; that column does not exist here +- always match the chosen asset using: + REPLACE(LOWER(a.name), ' ', '') = REPLACE(LOWER(''), ' ', '') +- OpenRemote time is already aligned with local time here, so do not apply a synthetic timezone offset +- keep the query to one statement only +- never write INSERT, UPDATE, DELETE, DROP, ALTER, or TRUNCATE +- return SQL only, no markdown fences and no explanation + +Use the asset context and the user request to decide: +- which telemetry keys to read +- whether the query should fetch latest rows or historical rows +- which computed columns are useful for later analysis +- whether the selected asset is a parent/group and therefore requires a recursive child-asset query using openremote.asset.parent_id +- prefer capturing telemetry across multiple JSON-carrying attributes rather than assuming only one attribute name + +Classification rule: +- an asset is a real device only when its attributes contain keys beyond notes and location +- if it is only a category/group, do not expect direct sensor telemetry from it +- for category/group questions, fetch the relevant child devices and their telemetry +- categories can contain sub-categories and those can contain devices, so recursive hierarchy queries are allowed +- if a recursive CTE is used, it must start with `WITH RECURSIVE` + +Tank rule: +- for tank devices, actual tank level must be computed as MaxFt - TankOilLevelInFeet001 +- the two source values may come from different attributes, so inspect all JSON-bearing attributes for `MaxFt`, `TankOilLevelInFeet001`, or `Level` +- if that computed level would be negative, clamp it to 0 + +If the selected asset has no direct live data but has children: +- use a recursive query or direct child query on openremote.asset.parent_id +- then join the child assets to openremote.asset_datapoint via ad.entity_id = child_asset.id +- summarize the relevant child assets for the user request + +The result should be accurate, safe, and directly executable. diff --git a/agents/analysis/backend/app/services/__init__.py b/agents/analysis/backend/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/agents/analysis/backend/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/agents/analysis/backend/app/services/__pycache__/__init__.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf6630b601f52ef60b62d68bd77240a0aa7a53ef Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/analysis_agent.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/analysis_agent.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e47d79cc9c780ecc9766864469bd2bc25daaf57d Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/analysis_agent.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/asset_analysis_instructions.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/asset_analysis_instructions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..39e1a6feb84ecb117e02e891caf072cf056bf97a Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/asset_analysis_instructions.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/asset_catalog.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/asset_catalog.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..13640c802d34a70be3c6ed921d8845ac9ff90f7b Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/asset_catalog.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/data_repository.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/data_repository.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..29919d47f7ee7a2a223c5122f4de819fbe497e7b Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/data_repository.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/deterministic_analysis.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/deterministic_analysis.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..708977b1003871d00356e58a2b33e5e70ccdf0f9 Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/deterministic_analysis.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/live_asset_registry.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/live_asset_registry.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4eb697fb189404d939b5148fa50c460f189da229 Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/live_asset_registry.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/llm_service.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/llm_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9495b6344c4dc96c3b18e246159aa4fe855f615b Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/llm_service.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/nl_to_sql.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/nl_to_sql.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..21621b50c00570c511e839a75f541231dc40f312 Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/nl_to_sql.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/report_builder.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/report_builder.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c7e5756bff467d25ed3bfa14f09dbb79b9c0ed9 Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/report_builder.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/scheduling_agent.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/scheduling_agent.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4689f104e2f81870f8a486bd9eaa686949a30fbc Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/scheduling_agent.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/sql_guard.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/sql_guard.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47754fd18a97fe4124bcb26a92ca6ed0dd2837a4 Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/sql_guard.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/__pycache__/trend_chart_service.cpython-313.pyc b/agents/analysis/backend/app/services/__pycache__/trend_chart_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9a5563dcff1120519817f8736b57c0e6201d9722 Binary files /dev/null and b/agents/analysis/backend/app/services/__pycache__/trend_chart_service.cpython-313.pyc differ diff --git a/agents/analysis/backend/app/services/analysis_agent.py b/agents/analysis/backend/app/services/analysis_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..a24c090fe9beadf1bac2dfee87650dd71fa6f2a3 --- /dev/null +++ b/agents/analysis/backend/app/services/analysis_agent.py @@ -0,0 +1,956 @@ +from __future__ import annotations + +import json +import math +import re +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +from fastapi import HTTPException + +from agents.analysis.backend.app.models.schemas import ( + AnalysisPlan, + AnalysisRequest, + AnalysisResponse, + ChatRequest, + ChatResponse, + TimeWindow, +) +from agents.analysis.backend.app.services.asset_analysis_instructions import build_asset_llm_context +from agents.analysis.backend.app.services.data_repository import DataQueryError, data_repository +from agents.analysis.backend.app.services.deterministic_analysis import deterministic_analysis_service +from agents.analysis.backend.app.services.live_asset_registry import live_asset_registry +from agents.analysis.backend.app.services.llm_service import llm_service +from agents.analysis.backend.app.services.nl_to_sql import nl_to_sql_service +from agents.analysis.backend.app.services.report_builder import report_builder_service +from agents.analysis.backend.app.services.sql_guard import validate_read_only_sql +from agents.analysis.backend.app.services.trend_chart_service import trend_chart_service + + +class AnalysisAgentService: + async def run_chat(self, payload: ChatRequest) -> ChatResponse: + from agents.analysis.backend.app.services.scheduling_agent import n8n_fast_path_service + + fast_path = n8n_fast_path_service.try_intercept(payload.message) + if fast_path: + return fast_path + + analysis_request = AnalysisRequest( + asset_id=payload.asset_id, + question=payload.message, + schema_name=payload.schema_name, + ) + try: + analysis = await self.run_analysis(analysis_request, user_id=payload.user_id) + return ChatResponse(answer=analysis.report.summary, analysis=analysis) + except HTTPException as exc: + if exc.status_code == 422: + return ChatResponse(answer=exc.detail, analysis=None) + raise exc + + + async def run_analysis(self, payload: AnalysisRequest, user_id: str = "user_1") -> AnalysisResponse: + # 1. Strict Binding: If a valid asset_id is provided in the payload, USE IT STRICTLY! + asset = None + planner_result = self._default_planner_result() + is_strictly_resolved = False + + if payload.asset_id and payload.asset_id != "string" and payload.asset_id.strip() != "": + asset = live_asset_registry.get_asset(payload.asset_id, user_id=user_id) + if asset: + planner_result["asset_id"] = payload.asset_id + planner_result["reasoning"] = "Asset ID was provided directly in request payload." + is_strictly_resolved = True + + # 2. Query Recognition Fallback: If asset_id is not provided, recognize it from the query message + if not asset: + candidates = live_asset_registry.find_candidates(payload.question, user_id=user_id) + direct_match = live_asset_registry.best_direct_match(payload.question, user_id=user_id) + if direct_match is not None: + planner_result["asset_id"] = direct_match.asset_id + planner_result["reasoning"] = "Direct asset match found from the user message." + else: + planner_result = llm_service.plan_request( + user_question=payload.question, + asset_catalog_context=self._build_asset_catalog_context(candidates), + ) + + asset = self._resolve_asset(payload.asset_id, planner_result, payload.question, candidates, user_id=user_id) + + # 3. Guardrails only for query-recognized assets (bypassed if strictly bound by ID) + if not is_strictly_resolved and asset: + if asset.asset_type == "tank" and "cold" in payload.question.lower() and "room" in payload.question.lower(): + for c in candidates: + if c.asset_type == "cold_room": + asset = c + planner_result["reasoning"] = "User asked for coldrooms, overriding tank selection with a valid coldroom candidate." + break + + analysis_name = payload.analysis_name or planner_result.get("analysis_name") or "historical_variance" + time_window = payload.time_window or self._parse_time_window(planner_result.get("time_window")) + explicit_time_window = self._extract_explicit_time_range_window(payload.question) + if explicit_time_window is not None: + started_at, ended_at, label = explicit_time_window + duration_hours = max( + 1, + int(math.ceil((ended_at - started_at).total_seconds() / 3600)), + ) + time_window = TimeWindow( + scope="historical", + value=duration_hours, + unit="hours", + label=label, + start_at=started_at.isoformat(), + end_at=ended_at.isoformat(), + ) + elif (relative_day_window := self._extract_relative_day_window(payload.question)) is not None: + started_at, ended_at, label = relative_day_window + duration_hours = max( + 1, + int(math.ceil((ended_at - started_at).total_seconds() / 3600)), + ) + time_window = TimeWindow( + scope="historical", + value=duration_hours, + unit="hours", + label=label, + start_at=started_at.isoformat(), + end_at=ended_at.isoformat(), + ) + elif (relative_duration_window := self._extract_relative_duration_window(payload.question)) is not None: + time_window = relative_duration_window + if explicit_time_window is None and (specific_date_window := self._extract_specific_date_window(payload.question)) is not None: + started_at, ended_at, label = specific_date_window + time_window = TimeWindow( + scope="historical", + value=1, + unit="days", + label=label, + start_at=started_at.isoformat(), + end_at=ended_at.isoformat(), + ) + if payload.time_window is None and not self._question_mentions_time_window(payload.question): + time_window = self._default_analysis_time_window() + asset_context = build_asset_llm_context(asset) + query_window = self._build_query_window(time_window) + + use_deterministic_sql = ( + asset.asset_type in {"tank", "cold_room", "smoke_alarm"} + or (not asset.is_device and asset.child_count > 0) + ) + sql_result = {"sql_query": "", "total_tokens": 0, "raw_response": ""} + if use_deterministic_sql: + sql_query = nl_to_sql_service.build_query( + asset=asset, + question=payload.question, + analysis_name=analysis_name, + time_window=time_window, + schema_name=payload.schema_name, + ).query + else: + sql_result = llm_service.generate_sql( + user_question=payload.question, + asset_context=asset_context, + analysis_name=analysis_name, + time_window_label=time_window.label, + ) + sql_query = nl_to_sql_service.normalize_generated_query(sql_result["sql_query"], asset) + + warnings: list[str] = [] + try: + validate_read_only_sql(sql_query) + except Exception: + fallback_sql = nl_to_sql_service.build_query( + asset=asset, + question=payload.question, + analysis_name=analysis_name, + time_window=time_window, + schema_name=payload.schema_name, + ) + sql_query = fallback_sql.query + warnings.append("The LLM SQL was invalid, so a safe fallback SQL template was used.") + + try: + rows = data_repository.execute_query(sql_query) + except DataQueryError as exc: + fallback_sql = nl_to_sql_service.build_query( + asset=asset, + question=payload.question, + analysis_name=analysis_name, + time_window=time_window, + schema_name=payload.schema_name, + ) + + sql_query = fallback_sql.query + warnings.append("The generated SQL failed at runtime, so a safe fallback query was used.") + try: + rows = data_repository.execute_query(sql_query) + except DataQueryError as retry_exc: + raise HTTPException(status_code=502, detail=str(retry_exc)) from retry_exc + rows = self._clip_rows_to_window(rows, time_window) + # Flatten rows to ensure nested sensor_data is usable by charting and deterministic tools + flattened_rows = [] + for row in rows: + new_row = dict(row) + sd = row.get("sensor_data") + if isinstance(sd, dict): + for k, v in sd.items(): + if k not in new_row: + new_row[k] = v + # Split values by attribute name dynamically + attr_name = row.get("attribute_name") + if attr_name and sd is not None and not isinstance(sd, dict): + try: + from decimal import Decimal + new_row[str(attr_name)] = float(Decimal(str(sd).strip())) + except Exception: + new_row[str(attr_name)] = sd + flattened_rows.append(new_row) + rows = flattened_rows + + + # Filter rows to only keep mentioned child assets if query is for a group/parent asset + if not asset.is_device and rows and any("asset_name" in r for r in rows): + mentioned_children = [] + all_assets = live_asset_registry.list_assets(user_id=user_id) + + # Helper to normalize and strip leading zeros from numbers + def normalize_clean(text): + if not text: return "" + norm = re.sub(r'[^a-zA-Z0-9]', '', text).lower() + return re.sub(r'(? 3600: + hours = int(diff.total_seconds() // 3600) + warnings.append( + f"The data for {asset.name} appears to be stale ({hours} hours old). " + f"Latest telemetry available is from {latest_row_time.strftime('%d %b %Y, %I:%M %p')} (Asia/Karachi)." + ) + else: + warnings.append( + f"Latest telemetry available for {asset.name} in this window is {latest_row_time.strftime('%d %b %Y, %I:%M %p')} (Asia/Karachi)." + ) + except Exception: + pass + + if asset.asset_type == "smoke_alarm": + final_analysis = { + "answer": self._build_smoke_alarm_answer(asset, time_window, deterministic_summary, rows), + "total_tokens": 0, + "raw_response": "", + "prompt_text": "", + } + else: + final_analysis = llm_service.analyze_result( + user_question=payload.question, + asset_context=asset_context, + sql_query=sql_query, + deterministic_findings=deterministic_summary, + rows=rows, + ) + + total_tokens = ( + int(sql_result.get("total_tokens", 0)) + + int(final_analysis.get("total_tokens", 0)) + ) + + plan = AnalysisPlan( + analysis_name=analysis_name, + reasoning=planner_result.get("reasoning", ""), + time_window=time_window, + query_started_at=query_window["started_at_iso"], + query_ended_at=query_window["ended_at_iso"], + query_window_label=query_window["label"], + planner_prompt=planner_result.get("prompt_text", ""), + sql_prompt=sql_result.get("prompt_text", ""), + analyst_prompt=final_analysis.get("prompt_text", ""), + sql_query=sql_query, + chart_hints=self._chart_hints_for_analysis(analysis_name), + warnings=warnings, + total_tokens=total_tokens, + llm_raw_response=final_analysis.get("raw_response", ""), + ) + report = report_builder_service.build( + asset=asset, + plan=plan, + rows=rows, + summary_override=final_analysis.get("answer", ""), + ) + trend_chart = trend_chart_service.build(asset, analysis_name, time_window, rows) + trend_charts = [trend_chart] if trend_chart else [] + return AnalysisResponse( + asset=asset, + plan=plan, + report=report, + trend_chart=trend_chart, + trend_charts=trend_charts, + rows=rows, + ) + + def _build_smoke_alarm_answer( + self, + asset, + time_window: TimeWindow, + deterministic_summary: dict[str, object], + rows: list[dict], + ) -> str: + findings = [str(item).strip() for item in deterministic_summary.get("findings", []) if str(item).strip()] + warnings = [str(item).strip() for item in deterministic_summary.get("warnings", []) if str(item).strip()] + + if not rows: + lines = [ + f"No smoke-alarm telemetry rows were found for {asset.name} in {time_window.label}.", + ] + if warnings: + lines.append("") + lines.extend(f"- {item}" for item in warnings[:4]) + return "\n".join(lines) + + headline = findings[0] if findings else f"{asset.name} smoke-alarm telemetry was analyzed for {time_window.label}." + latest_states = [item for item in findings[1:] if ": " in item][:6] + other_findings = [item for item in findings[1:] if item not in latest_states][:4] + + lines = [headline] + + if other_findings: + lines.append("") + lines.append("Key points") + lines.extend(f"- {item}" for item in other_findings) + + if latest_states: + lines.append("") + lines.append("Latest device states") + lines.extend(f"- {item}" for item in latest_states) + + if warnings: + lines.append("") + lines.append("Attention items") + lines.extend(f"- {item}" for item in warnings[:4]) + + return "\n".join(lines) + + def _apply_display_window_override( + self, + question: str, + time_window: TimeWindow, + query_window: dict[str, str], + ) -> dict[str, str]: + text = question.lower() + # Keep window as-is unless user asked for "today". + if "today" not in text: + return query_window + if not time_window.start_at: + return query_window + start_at = self._parse_datetime(time_window.start_at) + if start_at is None: + return query_window + end_at = self._parse_datetime(query_window.get("ended_at_iso", "")) + if end_at is None: + return query_window + + friendly_format = "%d %b %Y, %I:%M %p" + query_window["label"] = ( + f"{start_at.strftime(friendly_format)} to " + f"{end_at.strftime(friendly_format)} " + f"(Asia/Karachi)" + ) + return query_window + + def _latest_row_timestamp(self, rows: list[dict]) -> datetime | None: + timestamp_keys = ("recorded_at", "timestamp", "local_time", "raw_time", "latest_time") + latest: datetime | None = None + for row in rows: + for key in timestamp_keys: + value = row.get(key) + if value is None: + continue + parsed = self._parse_datetime(value) + if parsed is None: + continue + if latest is None or parsed > latest: + latest = parsed + break + return latest + + def _resolve_asset( + self, + requested_asset_id: str | None, + planner_result: dict, + question: str, + candidates: list, + user_id: str = "user_1", + ) -> object: + if requested_asset_id: + asset = live_asset_registry.get_asset(requested_asset_id, user_id=user_id) + if asset is not None: + return asset + + direct_match = live_asset_registry.best_direct_match(question, user_id=user_id) + if direct_match is not None: + return direct_match + + planned_asset_id = planner_result.get("asset_id", "") + if planned_asset_id: + asset = live_asset_registry.get_asset(planned_asset_id, user_id=user_id) + if asset is not None: + return asset + + # Check for user specified assets in query (e.g. tank 99, st-55, f-12) + import re + def _get_user_specified_assets(msg: str) -> list[tuple[str, str]]: + msg_lower = msg.lower() + pattern = r'\b(tank|coldroom|cold\s+room|physical|machine|boiler|refinery|st|f)\s*[-_]?\s*(\d+)\b' + return re.findall(pattern, msg_lower) + + user_specs = _get_user_specified_assets(question) + all_assets = live_asset_registry.list_assets(user_id) + available = list(dict.fromkeys(a.name for a in (all_assets[:3] or candidates[:3]))) + examples_str = ", ".join(available) if available else "Oil Tank 1, Cold Room 1" + + if user_specs: + spec_name = f"{user_specs[0][0]} {user_specs[0][1]}" + detail = f"I couldn't find the asset '{spec_name}' in your authorized assets. Please make sure the asset name is correct, or specify a valid asset (e.g., {examples_str})." + else: + detail = f"I noticed you are asking about telemetry analysis, but I couldn't identify which specific asset or device (e.g., {examples_str}) you are referring to. Could you please clarify?" + + raise HTTPException(status_code=422, detail=detail) + + def _parse_time_window(self, raw_time_window: dict | None) -> TimeWindow: + if not raw_time_window: + return self._default_analysis_time_window() + try: + return TimeWindow(**raw_time_window) + except Exception: + return self._default_analysis_time_window() + + def _default_analysis_time_window(self) -> TimeWindow: + return TimeWindow(scope="historical", value=3, unit="hours", label="last 3 hours") + + def _question_mentions_time_window(self, question: str) -> bool: + text = question.lower() + if self._extract_explicit_time_range_window(question) is not None: + return True + if self._extract_relative_duration_window(question) is not None: + return True + if self._extract_relative_day_window(question) is not None: + return True + if self._extract_specific_date_window(question) is not None: + return True + + time_words = ( + "today", + "yesterday", + "tomorrow", + "last", + "past", + "previous", + "between", + "from", + "to", + "since", + "until", + "till", + "hour", + "hours", + "minute", + "minutes", + "day", + "days", + "week", + "weeks", + "month", + "months", + ) + return any(word in text for word in time_words) + + def _build_query_window(self, time_window: TimeWindow) -> dict[str, str]: + timezone = ZoneInfo("Asia/Karachi") + if time_window.start_at and time_window.end_at: + started_at = datetime.fromisoformat(time_window.start_at) + ended_at = datetime.fromisoformat(time_window.end_at) + friendly_format = "%d %b %Y, %I:%M %p" + return { + "started_at_iso": started_at.isoformat(), + "ended_at_iso": ended_at.isoformat(), + "label": ( + f"{started_at.strftime(friendly_format)} to " + f"{ended_at.strftime(friendly_format)} (Asia/Karachi)" + ), + } + + ended_at = datetime.now(timezone) + + unit_to_delta = { + "minutes": timedelta(minutes=time_window.value), + "hours": timedelta(hours=time_window.value), + "days": timedelta(days=time_window.value), + "weeks": timedelta(weeks=time_window.value), + "months": timedelta(days=30 * time_window.value), + } + started_at = ended_at - unit_to_delta.get(time_window.unit, timedelta(hours=3)) + friendly_format = "%d %b %Y, %I:%M %p" + + return { + "started_at_iso": started_at.isoformat(), + "ended_at_iso": ended_at.isoformat(), + "label": ( + f"{started_at.strftime(friendly_format)} to " + f"{ended_at.strftime(friendly_format)} (Asia/Karachi)" + ), + } + + def _extract_specific_date_window(self, question: str) -> tuple[datetime, datetime, str] | None: + timezone = ZoneInfo("Asia/Karachi") + patterns = [ + r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b", + r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b", + r"\b(\d{1,2})-(\d{1,2})-(\d{4})\b", + ] + + for pattern in patterns: + match = re.search(pattern, question) + if not match: + continue + groups = match.groups() + try: + if pattern.startswith(r"\b(\d{4})"): + year, month, day = int(groups[0]), int(groups[1]), int(groups[2]) + else: + day, month, year = int(groups[0]), int(groups[1]), int(groups[2]) + anchor_date = datetime(year, month, day, 6, 0, 0, tzinfo=timezone) + end_date = anchor_date + timedelta(days=1) + label = ( + f"{anchor_date.strftime('%d %b %Y')} 06:00 AM to " + f"{end_date.strftime('%d %b %Y')} 06:00 AM" + ) + return anchor_date, end_date, label + except ValueError: + continue + + month_name_patterns = [ + r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\s*,?\s*(\d{4})\b", + r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\s*,?\s*(\d{4})\b", + r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\b", + r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\b", + ] + month_map = { + "jan": 1, "january": 1, + "feb": 2, "february": 2, + "mar": 3, "march": 3, + "apr": 4, "april": 4, + "may": 5, + "jun": 6, "june": 6, + "jul": 7, "july": 7, + "aug": 8, "august": 8, + "sep": 9, "sept": 9, "september": 9, + "oct": 10, "october": 10, + "nov": 11, "november": 11, + "dec": 12, "decebmber": 12, "december": 12, + } + now_local = datetime.now(timezone) + for pattern in month_name_patterns: + match = re.search(pattern, question, flags=re.IGNORECASE) + if not match: + continue + groups = match.groups() + try: + if pattern.startswith(r"\b(\d"): + day = int(groups[0]) + month = month_map.get(groups[1].lower()) + year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year + else: + month = month_map.get(groups[0].lower()) + day = int(groups[1]) + year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year + if month is None: + continue + anchor_date = datetime(year, month, day, 6, 0, 0, tzinfo=timezone) + end_date = anchor_date + timedelta(days=1) + label = ( + f"{anchor_date.strftime('%d %b %Y')} 06:00 AM to " + f"{end_date.strftime('%d %b %Y')} 06:00 AM" + ) + return anchor_date, end_date, label + except ValueError: + continue + + ordinal_only = re.search(r"\b(\d{1,2})(?:st|nd|rd|th)\b", question, flags=re.IGNORECASE) + if ordinal_only: + day = int(ordinal_only.group(1)) + year = now_local.year + month = now_local.month + if day > now_local.day: + if month == 1: + month = 12 + year -= 1 + else: + month -= 1 + try: + anchor_date = datetime(year, month, day, 6, 0, 0, tzinfo=timezone) + end_date = anchor_date + timedelta(days=1) + label = ( + f"{anchor_date.strftime('%d %b %Y')} 06:00 AM to " + f"{end_date.strftime('%d %b %Y')} 06:00 AM" + ) + return anchor_date, end_date, label + except ValueError: + return None + return None + + def _extract_relative_duration_window(self, question: str) -> TimeWindow | None: + lowered = question.lower() + + unit_map = { + "h": "hours", + "hr": "hours", + "hrs": "hours", + "hour": "hours", + "hours": "hours", + "d": "days", + "day": "days", + "days": "days", + "w": "weeks", + "wk": "weeks", + "wks": "weeks", + "week": "weeks", + "weeks": "weeks", + "m": "months", + "mo": "months", + "mon": "months", + "month": "months", + "months": "months", + "min": "minutes", + "mins": "minutes", + "minute": "minutes", + "minutes": "minutes", + } + + def build_window(value: int, raw_unit: str) -> TimeWindow | None: + unit = unit_map.get(raw_unit.lower()) + if unit is None: + return None + return TimeWindow( + scope="historical", + value=value, + unit=unit, + label=f"last {value} {unit}", + ) + + pattern = r"\b(?:last|past|for|previous|in)\s+(\d+)\s*(h|hr|hrs?|hours?|d|days?|w|wk|wks|weeks?|m|mo|mon|months?|min|mins?|minutes?)\b" + match = re.search(pattern, lowered) + if match: + built = build_window(int(match.group(1)), match.group(2)) + if built is not None: + return built + + pattern_single = r"\b(?:last|past|for|previous|in)\s+(?:an?|1)\s+(h|hr|hrs?|hours?|d|days?|w|wk|wks|weeks?|m|mo|mon|months?|min|mins?|minutes?)\b" + match_single = re.search(pattern_single, lowered) + if match_single: + built = build_window(1, match_single.group(1)) + if built is not None: + return built + + pattern_bare = r"\b(\d+)\s*(h|hr|hrs?|hours?|d|days?|w|wk|wks|weeks?|m|mo|mon|months?|min|mins?|minutes?)\b" + match_bare = re.search(pattern_bare, lowered) + if match_bare: + built = build_window(int(match_bare.group(1)), match_bare.group(2)) + if built is not None: + return built + + return None + + def _extract_explicit_time_range_window(self, question: str) -> tuple[datetime, datetime, str] | None: + timezone = ZoneInfo("Asia/Karachi") + lowered = question.lower() + range_match = re.search( + r"\b(?:from\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm))\s*(?:to|-|till|until)\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))\b", + lowered, + flags=re.IGNORECASE, + ) + if not range_match: + return None + + start_hm = self._parse_clock_token(range_match.group(1)) + end_hm = self._parse_clock_token(range_match.group(2)) + if start_hm is None or end_hm is None: + return None + + anchor = self._extract_anchor_date(question, timezone) or datetime.now(timezone).date() + started_at = datetime(anchor.year, anchor.month, anchor.day, start_hm[0], start_hm[1], tzinfo=timezone) + ended_at = datetime(anchor.year, anchor.month, anchor.day, end_hm[0], end_hm[1], tzinfo=timezone) + if ended_at <= started_at: + ended_at += timedelta(days=1) + + friendly_format = "%d %b %Y, %I:%M %p" + label = f"{started_at.strftime(friendly_format)} to {ended_at.strftime(friendly_format)} (Asia/Karachi)" + return started_at, ended_at, label + + def _parse_clock_token(self, token: str) -> tuple[int, int] | None: + match = re.match(r"^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*$", token.strip(), flags=re.IGNORECASE) + if not match: + return None + hour = int(match.group(1)) + minute = int(match.group(2) or "0") + meridian = match.group(3).lower() + if hour < 1 or hour > 12 or minute < 0 or minute > 59: + return None + if meridian == "am": + hour = 0 if hour == 12 else hour + else: + hour = 12 if hour == 12 else hour + 12 + return hour, minute + + def _extract_anchor_date(self, question: str, timezone: ZoneInfo) -> date | None: + patterns = [ + r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b", + r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b", + r"\b(\d{1,2})-(\d{1,2})-(\d{4})\b", + ] + + for pattern in patterns: + match = re.search(pattern, question) + if not match: + continue + groups = match.groups() + try: + if pattern.startswith(r"\b(\d{4})"): + year, month, day = int(groups[0]), int(groups[1]), int(groups[2]) + else: + day, month, year = int(groups[0]), int(groups[1]), int(groups[2]) + return date(year, month, day) + except ValueError: + continue + + month_name_patterns = [ + r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\s*,?\s*(\d{4})\b", + r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\s*,?\s*(\d{4})\b", + r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\b", + r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\b", + ] + month_map = { + "jan": 1, "january": 1, + "feb": 2, "february": 2, + "mar": 3, "march": 3, + "apr": 4, "april": 4, + "may": 5, + "jun": 6, "june": 6, + "jul": 7, "july": 7, + "aug": 8, "august": 8, + "sep": 9, "sept": 9, "september": 9, + "oct": 10, "october": 10, + "nov": 11, "november": 11, + "dec": 12, "decebmber": 12, "december": 12, + } + now_local = datetime.now(timezone) + for pattern in month_name_patterns: + match = re.search(pattern, question, flags=re.IGNORECASE) + if not match: + continue + groups = match.groups() + try: + if pattern.startswith(r"\b(\d"): + day = int(groups[0]) + month = month_map.get(groups[1].lower()) + year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year + else: + month = month_map.get(groups[0].lower()) + day = int(groups[1]) + year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year + if month is None: + continue + return date(year, month, day) + except ValueError: + continue + + ordinal_only = re.search(r"\b(\d{1,2})(?:st|nd|rd|th)\b", question, flags=re.IGNORECASE) + if ordinal_only: + day = int(ordinal_only.group(1)) + year = now_local.year + month = now_local.month + if day > now_local.day: + if month == 1: + month = 12 + year -= 1 + else: + month -= 1 + try: + return date(year, month, day) + except ValueError: + return None + return None + + def _extract_relative_day_window(self, question: str) -> tuple[datetime, datetime, str] | None: + text = question.lower() + timezone = ZoneInfo("Asia/Karachi") + now_local = datetime.now(timezone) + + if "today" in text or "scheduled" in text: + shift_start = now_local.replace(hour=6, minute=0, second=0, microsecond=0) + if now_local < shift_start: + shift_start = shift_start - timedelta(days=1) + label = ( + f"{shift_start.strftime('%d %b %Y')} 06:00 AM to " + f"{now_local.strftime('%d %b %Y %I:%M %p')}" + ) + return shift_start, now_local, label + + if "yesterday" in text: + today_shift_start = now_local.replace(hour=6, minute=0, second=0, microsecond=0) + if now_local < today_shift_start: + today_shift_start = today_shift_start - timedelta(days=1) + yesterday_shift_start = today_shift_start - timedelta(days=1) + label = ( + f"{yesterday_shift_start.strftime('%d %b %Y')} 06:00 AM to " + f"{today_shift_start.strftime('%d %b %Y')} 06:00 AM" + ) + return yesterday_shift_start, today_shift_start, label + + return None + + def _clip_rows_to_window(self, rows: list[dict], time_window: TimeWindow) -> list[dict]: + if not rows or not time_window.start_at or not time_window.end_at: + return rows + try: + start_dt = datetime.fromisoformat(time_window.start_at) + end_dt = datetime.fromisoformat(time_window.end_at) + except ValueError: + return rows + + timestamp_keys = ("recorded_at", "timestamp", "local_time", "raw_time") + filtered: list[dict] = [] + for row in rows: + row_ts = None + for key in timestamp_keys: + value = row.get(key) + if value is None: + continue + row_ts = self._parse_datetime(value) + if row_ts is not None: + break + if row_ts is None: + filtered.append(row) + continue + + if row_ts.tzinfo is None and start_dt.tzinfo is not None: + row_ts = row_ts.replace(tzinfo=start_dt.tzinfo) + if start_dt <= row_ts < end_dt: + filtered.append(row) + return filtered + + def _parse_datetime(self, value: object) -> datetime | None: + if isinstance(value, datetime): + return value + text = str(value).strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + pass + for fmt in ( + "%d %b %Y, %I:%M:%S %p", + "%d %b %Y, %I:%M %p", + "%d-%m-%Y %I:%M %p", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + ): + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + return None + + def _default_planner_result(self) -> dict: + return { + "asset_id": "", + "analysis_name": "live_status", + "time_window": { + "scope": "historical", + "value": 3, + "unit": "hours", + "label": "last 3 hours", + }, + "reasoning": "Planner bypassed.", + "prompt_text": "[BYPASSED]", + } + + def _build_asset_catalog_context(self, candidates: list) -> str: + shortlisted = candidates[:4] if candidates else live_asset_registry.list_assets()[:5] + entries: list[str] = [] + total_chars = 0 + max_chars = 2400 + for asset in shortlisted: + aliases = [ + alias for alias in asset.lookup_names + if alias and alias.lower() != asset.name.lower() + ][:2] + entry = json.dumps( + { + "asset_id": asset.asset_id, + "name": asset.name, + "aliases": aliases, + "type": asset.asset_type, + "parent": asset.parent_name or "", + "child_count": asset.child_count, + "has_live_data": asset.has_live_data, + "data_keys": asset.data_keys[:4], + "supported_analyses": asset.supported_analyses[:3], + } + ) + if total_chars + len(entry) > max_chars: + break + entries.append(entry) + total_chars += len(entry) + return "\n".join(entries) + + def _chart_hints_for_analysis(self, analysis_name: str) -> list[str]: + mapping = { + "cooling_cycle_analysis": ["line_chart", "event_markers"], + "consumption_analysis": ["line_chart", "duration_summary", "event_markers"], + "live_status": ["stat_cards"], + "trend_analysis": ["line_chart"], + "historical_variance": ["line_chart", "box_plot"], + "throughput_utilization": ["bar_chart", "line_chart"], + "inventory_level_analysis": ["line_chart", "threshold_bands"], + "anomaly_detection": ["line_chart", "anomaly_table"], + } + return mapping.get(analysis_name, ["line_chart"]) + + +analysis_agent_service = AnalysisAgentService() diff --git a/agents/analysis/backend/app/services/asset_analysis_instructions.py b/agents/analysis/backend/app/services/asset_analysis_instructions.py new file mode 100644 index 0000000000000000000000000000000000000000..791d0f52a2bac4ed0fb489f9bf61ca83d1d1e8d1 --- /dev/null +++ b/agents/analysis/backend/app/services/asset_analysis_instructions.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from agents.analysis.backend.app.core.config import get_settings +from agents.analysis.backend.app.models.schemas import AssetProfile + + +ASSET_TYPE_ANALYSIS_GUIDANCE = { + "cold_room": """ +For cold rooms, prioritize: +- live device health and payload freshness +- current temperature and humidity +- acceptable temperature band compliance +- historical drift, highs, lows, and instability +- cooling cycle estimation and repeated threshold breaches +- consumption analysis from temperature history when the asset belongs to T & H Monitoring + +Preferred analysis choice: +- if user asks about live/current/status -> live_status +- if the asset is in T & H Monitoring and the user asks for a time window, prefer consumption_analysis +- if user asks about temperature pattern, cooling, stability -> cooling_cycle_analysis +- if user asks for previous day/week/month comparison -> historical_variance +- if user asks about abnormal behavior -> anomaly_detection +""".strip(), + "filling_machine": """ +For filling machines, prioritize: +- current production counters +- per-shift throughput +- stale payload detection +- production movement over the requested time window +- identifying flat output, drops, or sudden spikes + +Preferred analysis choice: +- if user asks about live/current/status -> live_status +- if user asks about production, throughput, utilization -> throughput_utilization +- if user asks about previous windows or comparisons -> historical_variance +- if user asks about unusual behavior -> anomaly_detection +""".strip(), + "tank": """ +For tanks, prioritize: +- current level +- short-term and long-term rate of change +- refill and depletion pattern +- sharp drops and low-level risk +- stale payload detection + +Preferred analysis choice: +- if user asks about live/current/status -> live_status +- if user asks about level movement or stock behavior -> inventory_level_analysis +- if user asks for historical movement -> trend_analysis +- if user asks about unusual drops -> anomaly_detection +""".strip(), + "smoke_alarm": """ +For smoke alarm systems, prioritize: +- Enlisting ALL assets and ALL their attributes in a concise summary. +- Use the 'static_attributes' JSON provided in the data rows to discover all configured attributes for an asset, even if they have no live telemetry. +- If all are okay, keep the per-asset summary brief (one line per asset). +- If smoke, fire, fault, or low-battery is detected, provide the detailed breakdown immediately for that specific asset. +- Highlight smoke/fire detection events prominently. +- Ensure health status, battery, temperature, and humidity are mentioned for every device. + +Preferred analysis choice: +- if user asks about live/current/status -> live_status +- if user asks whether smoke/fire happened -> anomaly_detection +- if user asks about a category/group -> group_status +""".strip(), + "energy_meter": """ +For energy meters, prioritize: +- overall power and energy consumption +- peak usage identification +- breaking down consumption by child meters +- power factor anomalies +- voltage or current imbalances + +Preferred analysis choice: +- if user asks about live/current/status -> live_status +- if user asks about consumption, peak usage, or breakdown -> consumption_analysis +- if user asks about historical trends -> trend_analysis +- if user asks about a category/group -> group_status +""".strip(), +} + + +ASSET_NAME_OVERRIDES = { + "Cold Room 1": """ +This cold room should be treated as a temperature-preservation asset. +The final answer should clearly mention whether it is stable, outside band, +or stale/offline, and whether temperature movement suggests a cooling issue. +""".strip(), + "Filling Machine 1": """ +This machine should be treated as a throughput asset. +The final answer should clearly mention total count movement, strongest shift, +and whether the payload looks stale or flat. +""".strip(), + "Oil Tank 1": """ +This tank should be treated as an inventory asset. +The final answer should clearly mention current level, whether the trend is falling, +and whether the level looks risky. +""".strip(), +} + + +def build_asset_llm_context(asset: AssetProfile) -> str: + parts = [ + f"Asset selected from chat: {asset.name}", + f"Asset type: {asset.asset_type}", + f"Is device with sensor data: {asset.is_device}", + f"Parent asset: {asset.parent_name or 'None'}", + f"Direct child count: {asset.child_count}", + f"Asset description: {asset.description}", + f"Lookup aliases: {', '.join(asset.lookup_names) if asset.lookup_names else asset.name}", + f"Supported analyses: {', '.join(asset.supported_analyses)}", + f"Important metrics: {', '.join(asset.metrics)}", + f"Attribute keys: {', '.join(asset.attribute_keys[:20]) if asset.attribute_keys else 'None'}", + ( + "Exact telemetry keys: " + f"temperature={asset.temperature_keys}, " + f"humidity={asset.humidity_keys}, " + f"level={asset.level_keys}, " + f"production={asset.production_keys}" + ), + ( + "OpenRemote SQL facts: " + "join openremote.asset_datapoint to openremote.asset using ad.entity_id = a.id, " + "telemetry is usually under ad.value JSON, " + "multiple attributes can contain JSON sensor payloads, " + "the analysis should inspect all relevant JSON-carrying attributes unless the user narrows the request, " + "historical data should be read from asset datapoints, " + f"and OpenRemote time should be adjusted to local time by adding the {get_settings().local_time_offset_hours}-hour offset (e.g. ad.timestamp + INTERVAL '{get_settings().local_time_offset_hours} hours')." + ), + ( + "Device vs category rule: only assets whose attributes contain keys other than notes and location " + "should be treated as real sensor devices. Parent/group assets should be analyzed via their child assets." + ), + ( + f"Nominal thresholds: {asset.nominal_range if asset.nominal_range else {'min_temperature': 5.0, 'max_temperature': 45.0}}. " + "Use these thresholds for compliance checks and avoid introducing any unstated default range." + ), + ] + + if asset.asset_type == "tank": + parts.append( + "Tank rule: for tank-related devices, the actual physical liquid height is MaxFt - TankOilLevelInFeet001. " + "These values can come from two different attributes, so the analysis must combine them before drawing conclusions. " + "The final analysis should prioritize showing the percentage filled (e.g., 75% filled) rather than just the height, " + "using the formula (liquid height / MaxFt) * 100. " + "Do not explain how you calculated the level or percentage to the user." + ) + + if "t & h monitoring" in f"{asset.name} {asset.parent_name}".lower(): + parts.append( + "T & H Monitoring consumption rule: determine cold-room ON/OFF state from temperature trend rather than a direct signal. " + "A cooling cycle starts at the last peak before temperature begins decreasing and later drops below 20C. " + "The cycle stays ON while temperature remains below 20C and ends at the valley before temperature rises back to 20C or above." + "Sum all such cycle durations across the requested window, clip any cycle that began before the shift to 6:00 AM, " + "and if a cycle is still active at the end of the data, count it up to the latest timestamp." + ) + + if asset.asset_type == "smoke_alarm": + parts.append( + "Smoke-alarm rule: inspect every relevant JSON-carrying attribute for each end device. " + "Treat warn='warn' as smoke/fire detected, warn='mute' as alarm was muted after detection, " + "warn='fault' as device fault, warn='remove' as device removed, warn='install' as device installed, " + "warn='low-vol' or warn='low-vol-test' as low battery/voltage, and warn='ok' as normal. " + "The answer must clearly state whether smoke/fire was detected and include the timestamps of those events." + ) + + type_guidance = ASSET_TYPE_ANALYSIS_GUIDANCE.get(asset.asset_type) + if type_guidance: + parts.append(type_guidance) + + override = ASSET_NAME_OVERRIDES.get(asset.name) + if override: + parts.append(override) + + if asset.analysis_instructions: + parts.append(f"Asset-specific rules: {asset.analysis_instructions}") + + return "\n\n".join(parts) diff --git a/agents/analysis/backend/app/services/asset_catalog.py b/agents/analysis/backend/app/services/asset_catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..92689da81e7f0d510c350c64de24d346e1d24e14 --- /dev/null +++ b/agents/analysis/backend/app/services/asset_catalog.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from agents.analysis.backend.app.models.schemas import AssetProfile + + +class AssetCatalogService: + def __init__(self) -> None: + self._catalog_path = Path(__file__).resolve().parents[2] / "data" / "asset_catalog.yaml" + self._cache: dict[str, AssetProfile] | None = None + + def _load(self) -> dict[str, AssetProfile]: + raw = yaml.safe_load(self._catalog_path.read_text(encoding="utf-8")) + assets = raw.get("assets", []) + return { + asset["asset_id"]: AssetProfile(**asset) + for asset in assets + } + + def list_assets(self) -> list[AssetProfile]: + self._cache = self._load() + return list(self._cache.values()) + + def get_asset(self, asset_id: str) -> AssetProfile | None: + self._cache = self._load() + return self._cache.get(asset_id) + + def get_asset_by_name(self, name: str) -> AssetProfile | None: + normalized_name = self._normalize(name) + for asset in self.list_assets(): + aliases = [asset.name, asset.asset_id, *asset.lookup_names] + if any(self._normalize(alias) == normalized_name for alias in aliases): + return asset + return None + + def find_asset_by_message(self, message: str) -> AssetProfile | None: + normalized_message = self._normalize(message) + best_match: AssetProfile | None = None + best_length = -1 + + for asset in self.list_assets(): + aliases = [asset.asset_id, asset.name, *asset.lookup_names] + for alias in aliases: + normalized_alias = self._normalize(alias) + if normalized_alias and normalized_alias in normalized_message: + if len(normalized_alias) > best_length: + best_match = asset + best_length = len(normalized_alias) + return best_match + + def list_asset_names(self) -> list[str]: + names: list[str] = [] + for asset in self.list_assets(): + names.append(asset.name) + names.extend(asset.lookup_names) + return sorted(set(names)) + + def _normalize(self, value: str) -> str: + return "".join(value.lower().split()) + + +asset_catalog_service = AssetCatalogService() diff --git a/agents/analysis/backend/app/services/data_repository.py b/agents/analysis/backend/app/services/data_repository.py new file mode 100644 index 0000000000000000000000000000000000000000..c7ee23e909dcbc6068c48b8ea4b0168ce9517370 --- /dev/null +++ b/agents/analysis/backend/app/services/data_repository.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from datetime import date, datetime + +from sqlalchemy import create_engine, text +from sqlalchemy.exc import SQLAlchemyError + +from agents.analysis.backend.app.core.config import get_settings + + +class DataQueryError(RuntimeError): + pass + + +class DataRepository: + def __init__(self) -> None: + self._settings = get_settings() + self._engine = create_engine( + self._normalize_database_url(self._settings.database_url), + future=True, + pool_pre_ping=True, + ) + + def execute_query(self, query: str, params: dict | None = None) -> list[dict]: + try: + with self._engine.connect() as connection: + result = connection.execute(text(query), params or {}) + return [self._normalize_row(dict(row._mapping)) for row in result] + except SQLAlchemyError as exc: + raise DataQueryError(f"Database query failed: {exc.__class__.__name__}: {exc}") from exc + + def _normalize_database_url(self, database_url: str) -> str: + if database_url.startswith("postgresql://"): + return database_url.replace("postgresql://", "postgresql+psycopg://", 1) + return database_url + + def _normalize_row(self, row: dict) -> dict: + normalized: dict = {} + for key, value in row.items(): + if isinstance(value, datetime): + normalized[key] = value.strftime("%d %b %Y, %I:%M:%S %p") + elif isinstance(value, date): + normalized[key] = value.strftime("%d %b %Y") + else: + normalized[key] = value + return normalized + + +data_repository = DataRepository() diff --git a/agents/analysis/backend/app/services/deterministic_analysis.py b/agents/analysis/backend/app/services/deterministic_analysis.py new file mode 100644 index 0000000000000000000000000000000000000000..8f57bb1f17a461b04d904927245b22af48de7e35 --- /dev/null +++ b/agents/analysis/backend/app/services/deterministic_analysis.py @@ -0,0 +1,638 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation +from datetime import datetime, time +from statistics import mean +from collections import defaultdict + +from agents.analysis.backend.app.models.schemas import AssetProfile + + +class DeterministicAnalysisService: + def summarize(self, asset: AssetProfile, rows: list[dict]) -> dict[str, object]: + base = self._generic_summary(rows) + domain_summary = self._asset_specific_summary(asset, rows) + base["warnings"].extend(domain_summary["warnings"]) + base["findings"] = domain_summary["findings"] + return base + + def _generic_summary(self, rows: list[dict]) -> dict[str, object]: + if not rows: + return { + "numeric_metrics": {}, + "warnings": ["No telemetry rows were returned for the requested window."], + "findings": [], + } + + numeric_columns: dict[str, list[float]] = {} + for row in rows: + for key, value in row.items(): + if isinstance(value, bool): + continue + numeric_value = self._as_float(value) + if numeric_value is not None: + numeric_columns.setdefault(key, []).append(numeric_value) + + metric_summary: dict[str, dict[str, float]] = {} + warnings: list[str] = [] + + for column, values in numeric_columns.items(): + if not values: + continue + avg = mean(values) + metric_summary[column] = { + "min": min(values), + "max": max(values), + "avg": round(avg, 3), + "latest": values[-1], + } + + if len(values) > 1 and avg != 0: + spread = max(values) - min(values) + if spread > abs(avg) * 0.2: + warnings.append( + f"{column} changed noticeably across the requested window " + f"(min={min(values):.2f}, max={max(values):.2f})." + ) + + return { + "numeric_metrics": metric_summary, + "warnings": warnings, + "findings": self._generic_trend_findings(metric_summary), + } + + def _asset_specific_summary(self, asset: AssetProfile, rows: list[dict]) -> dict[str, object]: + if asset.asset_type == "cold_room": + return self._cold_room_summary(asset, rows) + if asset.asset_type == "filling_machine": + return self._filling_machine_summary(rows) + if asset.asset_type == "tank": + return self._tank_summary(asset, rows) + if asset.asset_type == "smoke_alarm": + return self._smoke_alarm_summary(asset, rows) + return {"warnings": [], "findings": []} + + def _smoke_alarm_summary(self, asset: AssetProfile, rows: list[dict]) -> dict[str, object]: + findings: list[str] = [] + warnings: list[str] = [] + + per_asset: dict[str, list[dict]] = defaultdict(list) + for row in rows: + asset_name = str(row.get("asset_name") or asset.name or "Unknown") + per_asset[asset_name].append(row) + + if not per_asset: + return {"warnings": ["No smoke-alarm telemetry rows were available for the requested window."], "findings": []} + + smoke_events: list[tuple[str, datetime | None, str]] = [] + muted_events: list[tuple[str, datetime | None, str]] = [] + fault_events: list[tuple[str, datetime | None, str]] = [] + remove_events: list[tuple[str, datetime | None, str]] = [] + low_voltage_assets: set[str] = set() + unknown_assets: set[str] = set() + latest_states: list[str] = [] + + for asset_name, asset_rows in sorted(per_asset.items()): + ordered = sorted(asset_rows, key=lambda row: self._parse_datetime(row.get("recorded_at") or row.get("timestamp")) or datetime.min) + per_sensor: dict[str, list[dict]] = defaultdict(list) + for row in ordered: + sensor_name = str(row.get("attribute_name") or row.get("sensor_name") or "sensor") + per_sensor[sensor_name].append(row) + + for row in ordered: + warn_type = self._normalize_warn_type(row.get("warn_type")) + event_time = self._parse_datetime(row.get("recorded_at") or row.get("timestamp")) + attribute_name = str(row.get("attribute_name") or row.get("sensor_name") or "sensor") + event_ref = (asset_name, event_time, attribute_name) + if warn_type == "warn": + smoke_events.append(event_ref) + elif warn_type == "mute": + muted_events.append(event_ref) + elif warn_type == "fault": + fault_events.append(event_ref) + elif warn_type == "remove": + remove_events.append(event_ref) + elif warn_type in {"low-vol", "low-vol-test"}: + low_voltage_assets.add(asset_name) + elif warn_type == "unknown": + unknown_assets.add(asset_name) + + latest_sensor_summaries: list[str] = [] + for sensor_name, sensor_rows in sorted(per_sensor.items()): + latest_row = sensor_rows[-1] + + # Strict smoke detection: only if warn_type is 'warn' + is_actual_smoke = any(str(r.get("warn_type", "")).lower() == "warn" for r in sensor_rows) + latest_warn: str | None = "SMOKE DETECTED" if is_actual_smoke else self._normalize_warn_type(latest_row.get("warn_type")) + if is_actual_smoke: + smoke_events.append((asset_name, self._parse_datetime(latest_row.get("recorded_at") or latest_row.get("timestamp")), sensor_name)) + + latest_time = self._parse_datetime(latest_row.get("recorded_at") or latest_row.get("timestamp")) + latest_temp = self._as_float(latest_row.get("temperature")) + latest_humidity = self._as_float(latest_row.get("humidity")) + latest_battery = self._as_float(latest_row.get("bat_percent")) + + raw_data = latest_row.get("sensor_data") + if raw_data is None and latest_warn is None: + # Explicit handling for assets with no data in the window + state_fragments = [f"{sensor_name}: No recent telemetry found (Offline)"] + latest_sensor_summaries.append(" ".join(state_fragments)) + continue + + state_fragments = [f"{sensor_name}: {latest_warn or 'ok'}"] + if latest_time is not None: + state_fragments.append(f"at {latest_time.strftime('%d %b %Y, %I:%M %p')}") + + details: list[str] = [] + if latest_temp is not None: + details.append(f"{latest_temp:.1f}C") + if latest_humidity is not None: + details.append(f"{latest_humidity:.1f}% RH") + if latest_battery is not None: + details.append(f"battery {latest_battery:.0f}%") + + if isinstance(raw_data, dict): + skip_keys = {"warn", "temp", "humi", "bat_percent", "bat_voltage", "name", "timestamp"} + for k, v in raw_data.items(): + if k.lower() in skip_keys: + continue + val = self._as_float(v) + if val is not None: + details.append(f"{k}: {val}") + elif isinstance(v, str) and len(v) < 20: + details.append(f"{k}: {v}") + else: + # Strict smoke detection: only if warn_type is 'warn' + warn_type = str(latest_row.get("warn_type", "")).lower() + is_actual_smoke = (warn_type == "warn") + + if is_actual_smoke: + smoke_events.append(latest_row) + latest_warn = "SMOKE DETECTED" + # If it's a simple value, include it directly + val = self._as_float(raw_data) + if val is not None: + details.append(f"value: {val}") + elif raw_data is not None: + details.append(f"value: {raw_data}") + + if details: + state_fragments.append(f"({', '.join(details)})") + + # For 'ok' status, show sensor name and details but omit the word 'ok'. + if not latest_warn or latest_warn == 'ok': + # state_fragments[0] is like "data: ok" — keep the sensor name prefix, drop 'ok' + sensor_label = state_fragments[0].split(":")[0].strip() + ":" + ok_parts = [sensor_label] + state_fragments[1:] # sensor_name: