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..2e8000491f8e5f283d2e80da1c5c880f9642feb8 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..35c125ccb8a38fc73bebb78c1a2d4240d942f65a 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..d12440cdc650130e27f0db0c59020ff1003a3fb1 --- /dev/null +++ b/SuperAgent/app/models/schemas.py @@ -0,0 +1,19 @@ +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 + schema_name: Optional[str] = None + + +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 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..4efa58c8273975c21c99cca64e932c71b2f81ba6 Binary files /dev/null and b/SuperAgent/app/services/__pycache__/asset_registry.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..56fff3e63c5b5860e68134626f1d70eb1129d1d6 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..62d0deaadc9b6ffde169e3e1b21da3c60181046f --- /dev/null +++ b/SuperAgent/app/services/asset_registry.py @@ -0,0 +1,324 @@ +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 get_summary(self, user_id: str) -> 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_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 + + # 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 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 + + normalized_query = self.normalize_name(query_name) + if not normalized_query: return None + + is_general_status = self._is_general_status_query(query_name) + + # 1. Exact normalized match (Highest Priority) + for a in allowed_assets: + if is_general_status and a['name'].lower() == 'status': + continue + if self.normalize_name(a['name']) == normalized_query: + return a + + # 2. Category-based numbered matching (Priority 2 - prevents generic overrides) + 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 a number that is NOT associated with time windows or limits (e.g., "10 minutes", "10 readings", "10 readngs" [typo]) + 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() + # Look at a small window around the match + 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 + + # Fallback to any first digit in the normalized query if no specific target is found + 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 + + if target_num is not None: + for a in allowed_assets: + if is_general_status and a['name'].lower() == 'status': + continue + db_name = a['name'].lower() + db_num_match = re.search(r'(\d+)', db_name) + if db_num_match and int(db_num_match.group(1)) == target_num: + cleaned_db = re.sub(r'[\s_-]', '', db_name) + is_db_tank = 'tank' in db_name or 'physical' in db_name or bool(re.match(r'^st\d+$', cleaned_db)) + if is_tank and is_db_tank: return a + if is_cold and 'cold' in db_name: return a + if is_boiler and 'boiler' in db_name: return a + if is_machine and ('machine' in db_name or 'filling' in db_name): return a + + # 3. Mentioned assets (Priority 3) + mentioned = self.find_mentioned_assets(query_name, user_id) + + # Category constraint filtering: e.g. if query contains "tank", do not match asset "Oil" + filtered_mentioned = [] + for a in mentioned: + db_name_lower = a['name'].lower() + 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)) + if is_tank and not is_db_tank: + continue + if is_cold and 'cold' not in db_name_lower: + continue + if is_boiler and 'boiler' not in db_name_lower: + continue + if is_machine and not any(k in db_name_lower for k in ['machine', 'filling']): + continue + filtered_mentioned.append(a) + + if len(filtered_mentioned) == 1: + return filtered_mentioned[0] + + if len(filtered_mentioned) >= 2: + # Multi-asset query! Check if they share a common parent + parent_ids = {a.get('parent_id') for a in filtered_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 + + # 4. Suffix-removed match (e.g. "Refinery Tank" -> "Refinery") + for suffix in ['tank', 'asset', 'sensor', 'machine', 'boiler', 'coldroom']: + if normalized_query.endswith(suffix): + short = normalized_query[:-len(suffix)] + for a in allowed_assets: + if is_general_status and a['name'].lower() == 'status': + continue + if self.normalize_name(a['name']) == short: + return a + + # 5. Fallback specific keyword match + for a in allowed_assets: + if is_general_status and a['name'].lower() == 'status': + continue + db_name = a['name'].lower() + db_norm = self.normalize_name(db_name) + cleaned_db = re.sub(r'[\s_-]', '', db_name) + is_db_tank = 'tank' in db_name or 'physical' in db_name or bool(re.match(r'^st\d+$', cleaned_db)) + if is_tank and is_db_tank: + if normalized_query in db_norm: return a + if is_cold and 'cold' in db_name: + if normalized_query in db_norm: return a + if is_boiler and 'boiler' in db_name: + if normalized_query in db_norm: return a + if is_machine and ('machine' in db_name or 'filling' in db_name): + if normalized_query in db_norm: return a + + # 6. Last resort: Generic substring match + if len(normalized_query) > 3: + for a in allowed_assets: + if is_general_status and a['name'].lower() == 'status': + continue + db_norm = self.normalize_name(a['name']) + # Do not return extremely generic names like "Oil" as a fallback substring match if they are not specifically asked for + if db_norm in ['oil', 'water', 'status', 'temp', 'level']: + continue + if normalized_query in db_norm or db_norm in normalized_query: + return a + + return None + + + return None + + 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/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..a7d1b0313e6f7266aa28198fa1f7c31a9130bd5a --- /dev/null +++ b/SuperAgent/app/services/router.py @@ -0,0 +1,898 @@ +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: Respect the explicit asset_id provided in the request payload + if state.get("asset_id"): + verified_asset = asset_registry.get_asset_by_id(state["asset_id"], state["user_id"]) + if verified_asset: + asset_id = verified_asset['id'] + asset_name = verified_asset['name'] + else: + logger.warning(f"User {state['user_id']} attempted to access unauthorized asset {state['asset_id']}") + + # 2. Second Priority: Fall back to fuzzy matching from the message ONLY if no explicit asset_id was provided + if not asset_id and 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: + 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 to protect all downstream agents + schema_name = payload.schema_name + if schema_name and schema_name.lower() != "openremote": + try: + from sqlalchemy import text + with asset_registry.SessionLocal() as session: + # Safe check on information_schema that doesn't trigger abort/exceptions + res = session.execute(text( + "SELECT 1 FROM information_schema.tables WHERE table_schema = :schema AND table_name = 'asset' LIMIT 1" + ), {"schema": schema_name}) + exists = res.scalar() is not None + if not exists: + print(f"WARNING: Schema '{schema_name}' does not contain the 'asset' table. Falling back to 'openremote'.") + schema_name = "openremote" + except Exception as e: + print(f"WARNING: Centralized schema verification failed for '{schema_name}': {e}. Falling back to 'openremote'.") + schema_name = "openremote" + else: + 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", "hy", "hello", "hey", "hola", "yo", "greetings", "howdy", "helo", "hii", "heyy", + "salam", "assalamualaikum", "namaste", "wassup", "sup" + } + + 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 + ) + 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 + ) + 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..431ad04ed9a45caa95957cae2983868c6ea58328 --- /dev/null +++ b/SuperAgent/main.py @@ -0,0 +1,282 @@ +# Trigger reload for agents/live changes +import os +import sys +from pathlib import Path + +# 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 +from fastapi.middleware.cors import CORSMiddleware +from dotenv import load_dotenv + +env_path = Path(__file__).parent.parent / ".env" +load_dotenv(dotenv_path=env_path) + +from app.models.schemas import ChatRequest, ChatResponse +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") + +# 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 + +@app.post("/api/v1/chat/message", response_model=ChatResponse) +async def chat_message(payload: ChatRequest) -> ChatResponse: + print(f"DEBUG: Received request: {payload}") + try: + return await router_service.route_request(payload) + 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/schedules") +async def list_schedules(): + port = os.getenv("PORT", "8000") + analysis_agent_url = os.getenv("ANALYSIS_AGENT_URL", f"http://127.0.0.1:{port}/analysis") + if "localhost" in analysis_agent_url or "127.0.0.1" in analysis_agent_url: + analysis_agent_url = re.sub(r":\d+", f":{port}", analysis_agent_url) + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{analysis_agent_url}/api/v1/schedules") + return response.json() + +@app.post("/api/v1/schedules/run/{schedule_id}") +async def run_schedule(schedule_id: str, offset: int = 0, span_days: int = 1): + port = os.getenv("PORT", "8000") + analysis_agent_url = os.getenv("ANALYSIS_AGENT_URL", f"http://127.0.0.1:{port}/analysis") + if "localhost" in analysis_agent_url or "127.0.0.1" in analysis_agent_url: + analysis_agent_url = re.sub(r":\d+", f":{port}", analysis_agent_url) + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{analysis_agent_url}/api/v1/schedules/run/{schedule_id}", + params={"offset": offset, "span_days": span_days} + ) + return response.json() + +@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 + +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..bd814fd9295491e9d6f1ce2c41e30d0e25d8d6d9 --- /dev/null +++ b/SuperAgent/scratch/debug_sql.txt @@ -0,0 +1,14 @@ +SELECT + a.name AS asset_name, + ad.timestamp + INTERVAL '3 hours' AS local_time, + jsonb_object_agg(ad.attribute_name, ad.value) AS sensor_data +FROM openremote.asset a +JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id +WHERE a.id = ( + SELECT id FROM openremote.asset + WHERE REPLACE(LOWER(name), ' ', '') = REPLACE(LOWER('Cold Room 01'), ' ', '') + ORDER BY version DESC LIMIT 1 +) +GROUP BY a.name, ad.timestamp +ORDER BY ad.timestamp DESC +LIMIT 1 \ 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..b973d8348fb6328abc78e573c4a041fd5a835304 --- /dev/null +++ b/SuperAgent/scratch/raw_llm_output.txt @@ -0,0 +1,65 @@ +Based on the system message, we need to decide whether SQL is needed and, if so, produce one safe SQL statement that follows the system message scenario rules. + +Since the question is asking for the latest temperature and humidity of Coldroom 1, we can use the `Scenario O: Custom Report / Multi-Asset` template. + +However, we need to modify the template to match the specific asset name and attribute names. We will use the `WHERE a.name IN (...)` condition to match the asset name. + +First, we need to convert the asset name to the correct format. Since the asset name is "Coldroom 1", we will convert it to "ColdRoom01". + +Next, we need to specify the attribute names for temperature and humidity. Since the attribute names are not provided in the context, we will assume they are "Temperature" and "Humidity". + +Here is the modified SQL statement: +```sql +WITH Latest AS ( + SELECT a.name, ad.timestamp + INTERVAL '3 hours' AS local_time, + NULLIF(a.attributes::jsonb->'Temperature'->'value'->>'Temperature', '')::numeric AS temperature, + NULLIF(a.attributes::jsonb->'Humidity'->'value'->>'Humidity', '')::numeric AS humidity + FROM openremote.asset a + JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id + WHERE a.name IN ('ColdRoom01') AND ad.attribute_name ILIKE 'data' +) +SELECT name, TO_CHAR(local_time, 'HH12:MI AM') AS time_label, temperature, humidity +FROM Latest +WHERE (a.attributes::jsonb->'Temperature'->'value'->>'Timestamp')::numeric > (CURRENT_TIMESTAMP - INTERVAL '30 minutes') +ORDER BY local_time DESC +LIMIT 1; +``` +However, this SQL statement is not following the system message scenario rules exactly. We need to use the `ROW_NUMBER()` function to get the latest data point for each asset. + +Here is the corrected SQL statement: +```sql +WITH Latest AS ( + SELECT a.name, ad.timestamp + INTERVAL '3 hours' AS local_time, + NULLIF(a.attributes::jsonb->'Temperature'->'value'->>'Temperature', '')::numeric AS temperature, + NULLIF(a.attributes::jsonb->'Humidity'->'value'->>'Humidity', '')::numeric AS humidity, + ROW_NUMBER() OVER (PARTITION BY a.id ORDER BY ad.timestamp DESC) AS rn + FROM openremote.asset a + JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id + WHERE a.name IN ('ColdRoom01') AND ad.attribute_name ILIKE 'data' +) +SELECT name, TO_CHAR(local_time, 'HH12:MI AM') AS time_label, temperature, humidity +FROM Latest +WHERE rn = 1 AND (a.attributes::jsonb->'Temperature'->'value'->>'Timestamp')::numeric > (CURRENT_TIMESTAMP - INTERVAL '30 minutes') +ORDER BY local_time DESC; +``` +This SQL statement follows the system message scenario rules exactly and should return the latest temperature and humidity of Coldroom 1. + +The final answer is: +```sql +needs_sql: true +sql: +WITH Latest AS ( + SELECT a.name, ad.timestamp + INTERVAL '3 hours' AS local_time, + NULLIF(a.attributes::jsonb->'Temperature'->'value'->>'Temperature', '')::numeric AS temperature, + NULLIF(a.attributes::jsonb->'Humidity'->'value'->>'Humidity', '')::numeric AS humidity, + ROW_NUMBER() OVER (PARTITION BY a.id ORDER BY ad.timestamp DESC) AS rn + FROM openremote.asset a + JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id + WHERE a.name IN ('ColdRoom01') AND ad.attribute_name ILIKE 'data' +) +SELECT name, TO_CHAR(local_time, 'HH12:MI AM') AS time_label, temperature, humidity +FROM Latest +WHERE rn = 1 AND (a.attributes::jsonb->'Temperature'->'value'->>'Timestamp')::numeric > (CURRENT_TIMESTAMP - INTERVAL '30 minutes') +ORDER BY local_time DESC; +``` +Follow-up suggestion: You can also check the latest temperature and humidity of other assets by modifying the `WHERE` clause to match the desired asset name. \ 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_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_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_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..69e7b5fd5fe758dc2b18ba4430113a59766da3eb 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..e6eeb5a2da459d395a3baea7026c9d7ff5f86744 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..7e546e99d4687577ff67e31a2128164b4c2316b0 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..a5b762c422dc5f96fe18ff8a19211ae44d0b547f --- /dev/null +++ b/agents/analysis/backend/app/services/analysis_agent.py @@ -0,0 +1,882 @@ +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, + ) + analysis = await self.run_analysis(analysis_request, user_id=payload.user_id) + return ChatResponse(answer=analysis.report.summary, analysis=analysis) + + + async def run_analysis(self, payload: AnalysisRequest, user_id: str = "user_1") -> AnalysisResponse: + 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) + planner_result = self._default_planner_result() + 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." + elif payload.asset_id: + planner_result["asset_id"] = payload.asset_id + planner_result["reasoning"] = "Asset id was provided directly in request." + 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) + + # Guardrail: If user asked for 'coldroom' but we picked a 'tank', check if we have better candidates + 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 + flattened_rows.append(new_row) + rows = flattened_rows + + latest_row_time = self._latest_row_timestamp(rows) + query_window = self._apply_display_window_override(payload.question, time_window, query_window) + + deterministic_summary = deterministic_analysis_service.summarize(asset, rows) + warnings.extend(list(deterministic_summary["warnings"])) + if latest_row_time is not None: + try: + request_end = datetime.fromisoformat(query_window["ended_at_iso"]) + if latest_row_time.tzinfo is None and request_end.tzinfo is not None: + latest_row_time = latest_row_time.replace(tzinfo=request_end.tzinfo) + if latest_row_time < request_end: + diff = request_end - latest_row_time + if diff.total_seconds() > 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 + + available = list(dict.fromkeys(asset.name for asset in (candidates[:12] or live_asset_registry.list_assets(user_id)[:12]))) + detail = "I could not identify the asset from your message." + if available: + detail += f" Available assets include: {', '.join(available)}." + 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: