Spaces:
Runtime error
Runtime error
Delete agents
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- agents/analysis/.DS_Store +0 -0
- agents/analysis/.env.example +0 -7
- agents/analysis/.gitignore +0 -9
- agents/analysis/backend/app/__init__.py +0 -1
- agents/analysis/backend/app/__pycache__/__init__.cpython-313.pyc +0 -0
- agents/analysis/backend/app/__pycache__/main.cpython-313.pyc +0 -0
- agents/analysis/backend/app/api/routes/__init__.py +0 -1
- agents/analysis/backend/app/api/routes/__pycache__/__init__.cpython-313.pyc +0 -0
- agents/analysis/backend/app/api/routes/__pycache__/analysis.cpython-313.pyc +0 -0
- agents/analysis/backend/app/api/routes/__pycache__/assets.cpython-313.pyc +0 -0
- agents/analysis/backend/app/api/routes/__pycache__/chat.cpython-313.pyc +0 -0
- agents/analysis/backend/app/api/routes/__pycache__/schedules.cpython-313.pyc +0 -0
- agents/analysis/backend/app/api/routes/analysis.py +0 -12
- agents/analysis/backend/app/api/routes/assets.py +0 -19
- agents/analysis/backend/app/api/routes/chat.py +0 -12
- agents/analysis/backend/app/api/routes/schedules.py +0 -42
- agents/analysis/backend/app/core/__pycache__/config.cpython-313.pyc +0 -0
- agents/analysis/backend/app/core/config.py +0 -28
- agents/analysis/backend/app/main.py +0 -33
- agents/analysis/backend/app/models/__init__.py +0 -1
- agents/analysis/backend/app/models/__pycache__/__init__.cpython-313.pyc +0 -0
- agents/analysis/backend/app/models/__pycache__/schemas.cpython-313.pyc +0 -0
- agents/analysis/backend/app/models/schemas.py +0 -123
- agents/analysis/backend/app/prompts/analysis_planner.txt +0 -24
- agents/analysis/backend/app/prompts/request_planner.txt +0 -37
- agents/analysis/backend/app/prompts/result_analyst.txt +0 -34
- agents/analysis/backend/app/prompts/sql_generator.txt +0 -46
- agents/analysis/backend/app/services/__init__.py +0 -1
- agents/analysis/backend/app/services/__pycache__/__init__.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/analysis_agent.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/asset_analysis_instructions.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/asset_catalog.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/data_repository.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/deterministic_analysis.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/live_asset_registry.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/llm_service.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/nl_to_sql.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/report_builder.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/scheduling_agent.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/sql_guard.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/__pycache__/trend_chart_service.cpython-313.pyc +0 -0
- agents/analysis/backend/app/services/analysis_agent.py +0 -882
- agents/analysis/backend/app/services/asset_analysis_instructions.py +0 -178
- agents/analysis/backend/app/services/asset_catalog.py +0 -65
- agents/analysis/backend/app/services/data_repository.py +0 -49
- agents/analysis/backend/app/services/deterministic_analysis.py +0 -638
- agents/analysis/backend/app/services/live_asset_registry.py +0 -469
- agents/analysis/backend/app/services/llm_service.py +0 -310
- agents/analysis/backend/app/services/nl_to_sql.py +0 -1553
- agents/analysis/backend/app/services/report_builder.py +0 -123
agents/analysis/.DS_Store
DELETED
|
Binary file (6.15 kB)
|
|
|
agents/analysis/.env.example
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
GROQ_API_KEY=
|
| 2 |
-
GROQ_MODEL=openai/gpt-oss-120b
|
| 3 |
-
DATABASE_URL=
|
| 4 |
-
LOCAL_TIMEZONE=Asia/Karachi
|
| 5 |
-
LOCAL_TIME_OFFSET_HOURS=3
|
| 6 |
-
OPENREMOTE_SCHEMA=openremote
|
| 7 |
-
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/.gitignore
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
.venv/
|
| 2 |
-
__pycache__/
|
| 3 |
-
.pytest_cache/
|
| 4 |
-
.next/
|
| 5 |
-
node_modules/
|
| 6 |
-
.env
|
| 7 |
-
dist/
|
| 8 |
-
build/
|
| 9 |
-
*.pyc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
|
|
|
|
|
|
agents/analysis/backend/app/__pycache__/__init__.cpython-313.pyc
DELETED
|
Binary file (179 Bytes)
|
|
|
agents/analysis/backend/app/__pycache__/main.cpython-313.pyc
DELETED
|
Binary file (1.47 kB)
|
|
|
agents/analysis/backend/app/api/routes/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
|
|
|
|
|
|
agents/analysis/backend/app/api/routes/__pycache__/__init__.cpython-313.pyc
DELETED
|
Binary file (190 Bytes)
|
|
|
agents/analysis/backend/app/api/routes/__pycache__/analysis.cpython-313.pyc
DELETED
|
Binary file (869 Bytes)
|
|
|
agents/analysis/backend/app/api/routes/__pycache__/assets.cpython-313.pyc
DELETED
|
Binary file (1.38 kB)
|
|
|
agents/analysis/backend/app/api/routes/__pycache__/chat.cpython-313.pyc
DELETED
|
Binary file (856 Bytes)
|
|
|
agents/analysis/backend/app/api/routes/__pycache__/schedules.cpython-313.pyc
DELETED
|
Binary file (2.44 kB)
|
|
|
agents/analysis/backend/app/api/routes/analysis.py
DELETED
|
@@ -1,12 +0,0 @@
|
|
| 1 |
-
from fastapi import APIRouter
|
| 2 |
-
|
| 3 |
-
from agents.analysis.backend.app.models.schemas import AnalysisRequest, AnalysisResponse
|
| 4 |
-
from agents.analysis.backend.app.services.analysis_agent import analysis_agent_service
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
router = APIRouter(tags=["analysis"])
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
@router.post("/analysis/run", response_model=AnalysisResponse)
|
| 11 |
-
async def run_analysis(payload: AnalysisRequest) -> AnalysisResponse:
|
| 12 |
-
return await analysis_agent_service.run_analysis(payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/api/routes/assets.py
DELETED
|
@@ -1,19 +0,0 @@
|
|
| 1 |
-
from fastapi import APIRouter, HTTPException
|
| 2 |
-
|
| 3 |
-
from agents.analysis.backend.app.services.live_asset_registry import live_asset_registry
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
router = APIRouter(tags=["assets"])
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
@router.get("/assets")
|
| 10 |
-
async def list_assets(user_id: str = "user_1") -> list[dict]:
|
| 11 |
-
return [asset.model_dump() for asset in live_asset_registry.list_assets(user_id=user_id)]
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
@router.get("/assets/{asset_id}")
|
| 15 |
-
async def get_asset(asset_id: str, user_id: str = "user_1") -> dict:
|
| 16 |
-
asset = live_asset_registry.get_asset(asset_id, user_id=user_id)
|
| 17 |
-
if asset is None:
|
| 18 |
-
raise HTTPException(status_code=404, detail=f"Unknown asset: {asset_id}")
|
| 19 |
-
return asset.model_dump()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/api/routes/chat.py
DELETED
|
@@ -1,12 +0,0 @@
|
|
| 1 |
-
from fastapi import APIRouter
|
| 2 |
-
|
| 3 |
-
from agents.analysis.backend.app.models.schemas import ChatRequest, ChatResponse
|
| 4 |
-
from agents.analysis.backend.app.services.analysis_agent import analysis_agent_service
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
router = APIRouter(tags=["chat"])
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
@router.post("/chat/message", response_model=ChatResponse)
|
| 11 |
-
async def chat_message(payload: ChatRequest) -> ChatResponse:
|
| 12 |
-
return await analysis_agent_service.run_chat(payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/api/routes/schedules.py
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 1 |
-
from fastapi import APIRouter, HTTPException
|
| 2 |
-
from typing import List, Optional
|
| 3 |
-
from pydantic import BaseModel
|
| 4 |
-
|
| 5 |
-
from agents.analysis.backend.app.models.schemas import ChatResponse
|
| 6 |
-
from agents.analysis.backend.app.services.scheduling_agent import n8n_fast_path_service
|
| 7 |
-
|
| 8 |
-
router = APIRouter(tags=["schedules"])
|
| 9 |
-
|
| 10 |
-
class ScheduleInfo(BaseModel):
|
| 11 |
-
id: str
|
| 12 |
-
name: str
|
| 13 |
-
description: str
|
| 14 |
-
triggers: List[str]
|
| 15 |
-
|
| 16 |
-
@router.get("/schedules", response_model=List[ScheduleInfo])
|
| 17 |
-
async def list_schedules():
|
| 18 |
-
"""List all available scheduled report types."""
|
| 19 |
-
return [
|
| 20 |
-
ScheduleInfo(
|
| 21 |
-
id="th_monitoring",
|
| 22 |
-
name="T & H Monitoring Report",
|
| 23 |
-
description="Generates a comprehensive Temperature and Humidity report for all coldrooms and labs.",
|
| 24 |
-
triggers=["Scheduled T&H Monitoring", "T&H Monitoring Schedule"]
|
| 25 |
-
),
|
| 26 |
-
ScheduleInfo(
|
| 27 |
-
id="filling_machines",
|
| 28 |
-
name="Filling Machines Production Report",
|
| 29 |
-
description="Generates a production summary for all filling machines across shifts.",
|
| 30 |
-
triggers=["Schedule Filling Machines", "Scheduled Filling Machines"]
|
| 31 |
-
)
|
| 32 |
-
]
|
| 33 |
-
|
| 34 |
-
@router.post("/schedules/run/{schedule_id}", response_model=ChatResponse)
|
| 35 |
-
async def run_schedule(schedule_id: str, offset: int = 0, span_days: int = 1):
|
| 36 |
-
"""Manually trigger a scheduled report by its ID."""
|
| 37 |
-
if schedule_id == "th_monitoring":
|
| 38 |
-
return n8n_fast_path_service._handle_th(offset=offset, span_days=span_days)
|
| 39 |
-
elif schedule_id == "filling_machines":
|
| 40 |
-
return n8n_fast_path_service._handle_filling(offset=offset, span_days=span_days)
|
| 41 |
-
else:
|
| 42 |
-
raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' not found.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/core/__pycache__/config.cpython-313.pyc
DELETED
|
Binary file (1.51 kB)
|
|
|
agents/analysis/backend/app/core/config.py
DELETED
|
@@ -1,28 +0,0 @@
|
|
| 1 |
-
from pathlib import Path
|
| 2 |
-
from functools import lru_cache
|
| 3 |
-
|
| 4 |
-
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
ROOT_ENV_FILE = Path(__file__).resolve().parents[5] / ".env"
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class Settings(BaseSettings):
|
| 11 |
-
app_name: str = "VisBot Analysis"
|
| 12 |
-
groq_api_key: str = ""
|
| 13 |
-
groq_model: str = "lgpt-oss-120b"
|
| 14 |
-
database_url: str = "sqlite+pysqlite:///:memory:"
|
| 15 |
-
local_timezone: str = "Asia/Karachi"
|
| 16 |
-
local_time_offset_hours: int = 3
|
| 17 |
-
openremote_schema: str = "openremote"
|
| 18 |
-
|
| 19 |
-
model_config = SettingsConfigDict(
|
| 20 |
-
env_file=(str(ROOT_ENV_FILE), ".env"),
|
| 21 |
-
env_file_encoding="utf-8",
|
| 22 |
-
extra="ignore",
|
| 23 |
-
)
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
@lru_cache
|
| 27 |
-
def get_settings() -> Settings:
|
| 28 |
-
return Settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/main.py
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
from fastapi import FastAPI
|
| 2 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
-
|
| 4 |
-
from agents.analysis.backend.app.api.routes.analysis import router as analysis_router
|
| 5 |
-
from agents.analysis.backend.app.api.routes.assets import router as assets_router
|
| 6 |
-
from agents.analysis.backend.app.api.routes.chat import router as chat_router
|
| 7 |
-
from agents.analysis.backend.app.api.routes.schedules import router as schedules_router
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
app = FastAPI(
|
| 11 |
-
title="VisBot Analysis",
|
| 12 |
-
version="0.1.0",
|
| 13 |
-
description="Intelligent asset analysis agent powered by FastAPI, LangChain, and Groq.",
|
| 14 |
-
)
|
| 15 |
-
|
| 16 |
-
app.add_middleware(
|
| 17 |
-
CORSMiddleware,
|
| 18 |
-
allow_origins=["*"],
|
| 19 |
-
allow_credentials=True,
|
| 20 |
-
allow_methods=["*"],
|
| 21 |
-
allow_headers=["*"],
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
@app.get("/health")
|
| 26 |
-
async def health() -> dict[str, str]:
|
| 27 |
-
return {"status": "ok"}
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
app.include_router(assets_router, prefix="/api/v1")
|
| 31 |
-
app.include_router(chat_router, prefix="/api/v1")
|
| 32 |
-
app.include_router(analysis_router, prefix="/api/v1")
|
| 33 |
-
app.include_router(schedules_router, prefix="/api/v1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/models/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
|
|
|
|
|
|
agents/analysis/backend/app/models/__pycache__/__init__.cpython-313.pyc
DELETED
|
Binary file (186 Bytes)
|
|
|
agents/analysis/backend/app/models/__pycache__/schemas.cpython-313.pyc
DELETED
|
Binary file (6.26 kB)
|
|
|
agents/analysis/backend/app/models/schemas.py
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from datetime import datetime
|
| 4 |
-
from typing import Literal
|
| 5 |
-
from uuid import uuid4
|
| 6 |
-
|
| 7 |
-
from pydantic import BaseModel, Field
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
AnalysisScope = Literal["live", "historical"]
|
| 11 |
-
WindowUnit = Literal["minutes", "hours", "days", "weeks", "months"]
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class TimeWindow(BaseModel):
|
| 15 |
-
scope: AnalysisScope = "live"
|
| 16 |
-
value: int = 1
|
| 17 |
-
unit: WindowUnit = "hours"
|
| 18 |
-
label: str = "last 1 hour"
|
| 19 |
-
start_at: str | None = None
|
| 20 |
-
end_at: str | None = None
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
class AssetProfile(BaseModel):
|
| 24 |
-
asset_id: str
|
| 25 |
-
name: str
|
| 26 |
-
asset_type: str
|
| 27 |
-
description: str
|
| 28 |
-
db_asset_id: str = ""
|
| 29 |
-
parent_name: str = ""
|
| 30 |
-
child_count: int = 0
|
| 31 |
-
is_device: bool = False
|
| 32 |
-
attribute_keys: list[str] = Field(default_factory=list)
|
| 33 |
-
has_live_data: bool = False
|
| 34 |
-
data_keys: list[str] = Field(default_factory=list)
|
| 35 |
-
supported_analyses: list[str] = Field(default_factory=list)
|
| 36 |
-
metrics: list[str] = Field(default_factory=list)
|
| 37 |
-
source_table: str = ""
|
| 38 |
-
analysis_instructions: str = ""
|
| 39 |
-
sql_notes: str = ""
|
| 40 |
-
lookup_names: list[str] = Field(default_factory=list)
|
| 41 |
-
temperature_keys: list[str] = Field(default_factory=list)
|
| 42 |
-
humidity_keys: list[str] = Field(default_factory=list)
|
| 43 |
-
level_keys: list[str] = Field(default_factory=list)
|
| 44 |
-
production_keys: list[str] = Field(default_factory=list)
|
| 45 |
-
nominal_range: dict[str, float] = Field(default_factory=dict)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
class AnalysisPlan(BaseModel):
|
| 49 |
-
analysis_name: str
|
| 50 |
-
reasoning: str
|
| 51 |
-
time_window: TimeWindow
|
| 52 |
-
query_started_at: str = ""
|
| 53 |
-
query_ended_at: str = ""
|
| 54 |
-
query_window_label: str = ""
|
| 55 |
-
planner_prompt: str = ""
|
| 56 |
-
sql_prompt: str = ""
|
| 57 |
-
analyst_prompt: str = ""
|
| 58 |
-
sql_query: str
|
| 59 |
-
chart_hints: list[str] = Field(default_factory=list)
|
| 60 |
-
warnings: list[str] = Field(default_factory=list)
|
| 61 |
-
total_tokens: int = 0
|
| 62 |
-
llm_raw_response: str = ""
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
class AnalysisReport(BaseModel):
|
| 66 |
-
report_id: str = Field(default_factory=lambda: str(uuid4()))
|
| 67 |
-
title: str
|
| 68 |
-
summary: str
|
| 69 |
-
markdown: str
|
| 70 |
-
html: str
|
| 71 |
-
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
class AnalysisRequest(BaseModel):
|
| 75 |
-
asset_id: str | None = None
|
| 76 |
-
question: str
|
| 77 |
-
analysis_name: str | None = None
|
| 78 |
-
time_window: TimeWindow | None = None
|
| 79 |
-
schema_name: str | None = None
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
class TrendPoint(BaseModel):
|
| 84 |
-
label: str
|
| 85 |
-
value: float
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
class TrendSeries(BaseModel):
|
| 89 |
-
key: str
|
| 90 |
-
label: str
|
| 91 |
-
color: str
|
| 92 |
-
points: list[TrendPoint] = Field(default_factory=list)
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
class TrendChart(BaseModel):
|
| 96 |
-
title: str
|
| 97 |
-
chart_type: Literal["line", "bar"]
|
| 98 |
-
x_label: str
|
| 99 |
-
y_label: str
|
| 100 |
-
summary: str = ""
|
| 101 |
-
series: list[TrendSeries] = Field(default_factory=list)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
class AnalysisResponse(BaseModel):
|
| 105 |
-
asset: AssetProfile
|
| 106 |
-
plan: AnalysisPlan
|
| 107 |
-
report: AnalysisReport
|
| 108 |
-
trend_chart: TrendChart | None = None
|
| 109 |
-
trend_charts: list[TrendChart] = Field(default_factory=list)
|
| 110 |
-
rows: list[dict] = Field(default_factory=list)
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
class ChatRequest(BaseModel):
|
| 114 |
-
message: str
|
| 115 |
-
user_id: str = "user_1"
|
| 116 |
-
asset_id: str | None = None
|
| 117 |
-
schema_name: str | None = None
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
class ChatResponse(BaseModel):
|
| 122 |
-
answer: str
|
| 123 |
-
analysis: AnalysisResponse
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/prompts/analysis_planner.txt
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
You are VisBot Analysis, an industrial asset analysis planner.
|
| 2 |
-
|
| 3 |
-
Your job:
|
| 4 |
-
- understand the user's asset analysis request
|
| 5 |
-
- use the asset selected from the user's chat message
|
| 6 |
-
- use the backend-provided asset analysis instructions
|
| 7 |
-
- select the best analysis type
|
| 8 |
-
- infer the time window
|
| 9 |
-
- produce a safe SQL query plan
|
| 10 |
-
|
| 11 |
-
Rules:
|
| 12 |
-
- prefer deterministic analysis types from the asset profile
|
| 13 |
-
- never invent unavailable metrics
|
| 14 |
-
- keep SQL read-only
|
| 15 |
-
- use the asset description to choose the correct analysis style
|
| 16 |
-
- explain the result in simple user-friendly language
|
| 17 |
-
- if the user asks for live data, prioritize the latest records
|
| 18 |
-
- if the user asks for historical analysis, use the requested time window
|
| 19 |
-
|
| 20 |
-
Return concise planning text that explains:
|
| 21 |
-
1. chosen analysis
|
| 22 |
-
2. why it matches the asset
|
| 23 |
-
3. what time window should be used
|
| 24 |
-
4. what metrics matter most
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/prompts/request_planner.txt
DELETED
|
@@ -1,37 +0,0 @@
|
|
| 1 |
-
You are VisBot Analysis.
|
| 2 |
-
|
| 3 |
-
Your job is to read the user's message and produce a structured asset-analysis plan.
|
| 4 |
-
|
| 5 |
-
You must:
|
| 6 |
-
- detect the exact asset from the provided asset catalog
|
| 7 |
-
- choose the most suitable analysis type
|
| 8 |
-
- infer the time window from the user's wording
|
| 9 |
-
- prefer historical analysis styles when the user asks for trends, patterns, previous windows, comparisons, changes, drops, rises, stability, or insights over time
|
| 10 |
-
- keep the output strictly in JSON
|
| 11 |
-
|
| 12 |
-
Rules:
|
| 13 |
-
- do not invent assets that are not in the catalog
|
| 14 |
-
- the provided asset catalog is a compact shortlist, so use the strongest name, alias, parent, and telemetry-key clues available
|
| 15 |
-
- prefer exact asset name matches while ignoring spaces and case
|
| 16 |
-
- pay attention to parent_name, child_count, has_live_data, and data_keys
|
| 17 |
-
- 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
|
| 18 |
-
- use the asset description and analysis instructions to decide the analysis style
|
| 19 |
-
- if the asset is unclear, return asset_id as an empty string and explain the issue briefly
|
| 20 |
-
- if the user asks for "latest", treat it as a live or recent window
|
| 21 |
-
- 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
|
| 22 |
-
- 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
|
| 23 |
-
- 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
|
| 24 |
-
- keep labels human-readable
|
| 25 |
-
|
| 26 |
-
Return JSON with this exact shape:
|
| 27 |
-
{{
|
| 28 |
-
"asset_id": "string",
|
| 29 |
-
"analysis_name": "string",
|
| 30 |
-
"time_window": {{
|
| 31 |
-
"scope": "live or historical",
|
| 32 |
-
"value": 1,
|
| 33 |
-
"unit": "minutes|hours|days|weeks|months",
|
| 34 |
-
"label": "string"
|
| 35 |
-
}},
|
| 36 |
-
"reasoning": "short string"
|
| 37 |
-
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/prompts/result_analyst.txt
DELETED
|
@@ -1,34 +0,0 @@
|
|
| 1 |
-
You are VisBot Analysis.
|
| 2 |
-
|
| 3 |
-
You are given:
|
| 4 |
-
- the user's original request
|
| 5 |
-
- the matched asset and its analysis instructions
|
| 6 |
-
- the SQL query that fetched data
|
| 7 |
-
- deterministic findings
|
| 8 |
-
- the returned database rows
|
| 9 |
-
|
| 10 |
-
Write a clean end-user analysis in simple language.
|
| 11 |
-
|
| 12 |
-
Rules:
|
| 13 |
-
- be analytical, not generic
|
| 14 |
-
- use the actual returned data
|
| 15 |
-
- highlight the most important findings first
|
| 16 |
-
- mention problems clearly if thresholds are crossed or data is stale
|
| 17 |
-
- avoid raw JSON formatting in the main answer
|
| 18 |
-
- keep the answer well structured and readable
|
| 19 |
-
- keep the answer concise and precise (prefer 5-10 short lines total)
|
| 20 |
-
- do not output empty bullet markers, standalone `*`, or markdown separators like `---`
|
| 21 |
-
- prefer short headings, clean paragraphs, and real lists only when each bullet has content
|
| 22 |
-
- 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
|
| 23 |
-
- when the request is historical, explicitly describe trend direction, range, highs, lows, movement, and stability using the returned rows
|
| 24 |
-
- provide real insights from the historical series instead of restating a single latest point
|
| 25 |
-
- for tanks, present the reported metric as percentage filled (e.g. 85%) whenever possible
|
| 26 |
-
- for tanks, use `percentage_filled = ((MaxFt - TankOilLevelInFeet001) / MaxFt) * 100` whenever MaxFt is available
|
| 27 |
-
- for tanks, do not explain or mention how you calculated the level or percentage, just report the final fill percentage and current depth
|
| 28 |
-
- if deterministic findings confirm that MaxFt was found, prioritize the percentage filled in your summary
|
| 29 |
-
- for temperature and humidity (T&H) monitoring, always report how much time the system was on or consuming power within the "6am-6am" day
|
| 30 |
-
- for all day-based calculations, a 24-hour day is strictly considered from 6:00 AM to 6:00 AM the following day
|
| 31 |
-
- 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
|
| 32 |
-
- for temperature compliance, use only thresholds explicitly provided in the asset context or deterministic findings
|
| 33 |
-
- do not use generic or assumed industry ranges (for example "usually 2-8C") unless the user explicitly asks for benchmark ranges
|
| 34 |
-
- finish with one useful suggested next step
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/prompts/sql_generator.txt
DELETED
|
@@ -1,46 +0,0 @@
|
|
| 1 |
-
You are VisBot Analysis.
|
| 2 |
-
|
| 3 |
-
Generate exactly one read-only PostgreSQL query for OpenRemote.
|
| 4 |
-
|
| 5 |
-
Database rules:
|
| 6 |
-
- all tables are under the openremote schema
|
| 7 |
-
- the main telemetry tables are openremote.asset and openremote.asset_datapoint
|
| 8 |
-
- join them using `ad.entity_id = a.id`
|
| 9 |
-
- sensor telemetry is usually stored in asset_datapoint.value JSON
|
| 10 |
-
- many telemetry rows use `attribute_name ILIKE 'data'`, but not all
|
| 11 |
-
- multiple attributes can contain JSON sensor payloads
|
| 12 |
-
- 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
|
| 13 |
-
- history is stored in asset datapoints, so historical queries should read from those datapoints directly
|
| 14 |
-
- do not use `ad.asset_id`; that column does not exist here
|
| 15 |
-
- always match the chosen asset using:
|
| 16 |
-
REPLACE(LOWER(a.name), ' ', '') = REPLACE(LOWER('<asset name>'), ' ', '')
|
| 17 |
-
- OpenRemote time is already aligned with local time here, so do not apply a synthetic timezone offset
|
| 18 |
-
- keep the query to one statement only
|
| 19 |
-
- never write INSERT, UPDATE, DELETE, DROP, ALTER, or TRUNCATE
|
| 20 |
-
- return SQL only, no markdown fences and no explanation
|
| 21 |
-
|
| 22 |
-
Use the asset context and the user request to decide:
|
| 23 |
-
- which telemetry keys to read
|
| 24 |
-
- whether the query should fetch latest rows or historical rows
|
| 25 |
-
- which computed columns are useful for later analysis
|
| 26 |
-
- whether the selected asset is a parent/group and therefore requires a recursive child-asset query using openremote.asset.parent_id
|
| 27 |
-
- prefer capturing telemetry across multiple JSON-carrying attributes rather than assuming only one attribute name
|
| 28 |
-
|
| 29 |
-
Classification rule:
|
| 30 |
-
- an asset is a real device only when its attributes contain keys beyond notes and location
|
| 31 |
-
- if it is only a category/group, do not expect direct sensor telemetry from it
|
| 32 |
-
- for category/group questions, fetch the relevant child devices and their telemetry
|
| 33 |
-
- categories can contain sub-categories and those can contain devices, so recursive hierarchy queries are allowed
|
| 34 |
-
- if a recursive CTE is used, it must start with `WITH RECURSIVE`
|
| 35 |
-
|
| 36 |
-
Tank rule:
|
| 37 |
-
- for tank devices, actual tank level must be computed as MaxFt - TankOilLevelInFeet001
|
| 38 |
-
- the two source values may come from different attributes, so inspect all JSON-bearing attributes for `MaxFt`, `TankOilLevelInFeet001`, or `Level`
|
| 39 |
-
- if that computed level would be negative, clamp it to 0
|
| 40 |
-
|
| 41 |
-
If the selected asset has no direct live data but has children:
|
| 42 |
-
- use a recursive query or direct child query on openremote.asset.parent_id
|
| 43 |
-
- then join the child assets to openremote.asset_datapoint via ad.entity_id = child_asset.id
|
| 44 |
-
- summarize the relevant child assets for the user request
|
| 45 |
-
|
| 46 |
-
The result should be accurate, safe, and directly executable.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/services/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
|
|
|
|
|
|
agents/analysis/backend/app/services/__pycache__/__init__.cpython-313.pyc
DELETED
|
Binary file (188 Bytes)
|
|
|
agents/analysis/backend/app/services/__pycache__/analysis_agent.cpython-313.pyc
DELETED
|
Binary file (39.4 kB)
|
|
|
agents/analysis/backend/app/services/__pycache__/asset_analysis_instructions.cpython-313.pyc
DELETED
|
Binary file (9.87 kB)
|
|
|
agents/analysis/backend/app/services/__pycache__/asset_catalog.cpython-313.pyc
DELETED
|
Binary file (4.68 kB)
|
|
|
agents/analysis/backend/app/services/__pycache__/data_repository.cpython-313.pyc
DELETED
|
Binary file (3.33 kB)
|
|
|
agents/analysis/backend/app/services/__pycache__/deterministic_analysis.cpython-313.pyc
DELETED
|
Binary file (34 kB)
|
|
|
agents/analysis/backend/app/services/__pycache__/live_asset_registry.cpython-313.pyc
DELETED
|
Binary file (27.6 kB)
|
|
|
agents/analysis/backend/app/services/__pycache__/llm_service.cpython-313.pyc
DELETED
|
Binary file (13 kB)
|
|
|
agents/analysis/backend/app/services/__pycache__/nl_to_sql.cpython-313.pyc
DELETED
|
Binary file (57.3 kB)
|
|
|
agents/analysis/backend/app/services/__pycache__/report_builder.cpython-313.pyc
DELETED
|
Binary file (6.35 kB)
|
|
|
agents/analysis/backend/app/services/__pycache__/scheduling_agent.cpython-313.pyc
DELETED
|
Binary file (49.9 kB)
|
|
|
agents/analysis/backend/app/services/__pycache__/sql_guard.cpython-313.pyc
DELETED
|
Binary file (1.12 kB)
|
|
|
agents/analysis/backend/app/services/__pycache__/trend_chart_service.cpython-313.pyc
DELETED
|
Binary file (14.4 kB)
|
|
|
agents/analysis/backend/app/services/analysis_agent.py
DELETED
|
@@ -1,882 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import json
|
| 4 |
-
import math
|
| 5 |
-
import re
|
| 6 |
-
from datetime import date, datetime, timedelta
|
| 7 |
-
from zoneinfo import ZoneInfo
|
| 8 |
-
|
| 9 |
-
from fastapi import HTTPException
|
| 10 |
-
|
| 11 |
-
from agents.analysis.backend.app.models.schemas import (
|
| 12 |
-
AnalysisPlan,
|
| 13 |
-
AnalysisRequest,
|
| 14 |
-
AnalysisResponse,
|
| 15 |
-
ChatRequest,
|
| 16 |
-
ChatResponse,
|
| 17 |
-
TimeWindow,
|
| 18 |
-
)
|
| 19 |
-
from agents.analysis.backend.app.services.asset_analysis_instructions import build_asset_llm_context
|
| 20 |
-
from agents.analysis.backend.app.services.data_repository import DataQueryError, data_repository
|
| 21 |
-
from agents.analysis.backend.app.services.deterministic_analysis import deterministic_analysis_service
|
| 22 |
-
from agents.analysis.backend.app.services.live_asset_registry import live_asset_registry
|
| 23 |
-
from agents.analysis.backend.app.services.llm_service import llm_service
|
| 24 |
-
from agents.analysis.backend.app.services.nl_to_sql import nl_to_sql_service
|
| 25 |
-
from agents.analysis.backend.app.services.report_builder import report_builder_service
|
| 26 |
-
from agents.analysis.backend.app.services.sql_guard import validate_read_only_sql
|
| 27 |
-
from agents.analysis.backend.app.services.trend_chart_service import trend_chart_service
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
class AnalysisAgentService:
|
| 31 |
-
async def run_chat(self, payload: ChatRequest) -> ChatResponse:
|
| 32 |
-
from agents.analysis.backend.app.services.scheduling_agent import n8n_fast_path_service
|
| 33 |
-
|
| 34 |
-
fast_path = n8n_fast_path_service.try_intercept(payload.message)
|
| 35 |
-
if fast_path:
|
| 36 |
-
return fast_path
|
| 37 |
-
|
| 38 |
-
analysis_request = AnalysisRequest(
|
| 39 |
-
asset_id=payload.asset_id,
|
| 40 |
-
question=payload.message,
|
| 41 |
-
schema_name=payload.schema_name,
|
| 42 |
-
)
|
| 43 |
-
analysis = await self.run_analysis(analysis_request, user_id=payload.user_id)
|
| 44 |
-
return ChatResponse(answer=analysis.report.summary, analysis=analysis)
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
async def run_analysis(self, payload: AnalysisRequest, user_id: str = "user_1") -> AnalysisResponse:
|
| 48 |
-
candidates = live_asset_registry.find_candidates(payload.question, user_id=user_id)
|
| 49 |
-
direct_match = live_asset_registry.best_direct_match(payload.question, user_id=user_id)
|
| 50 |
-
planner_result = self._default_planner_result()
|
| 51 |
-
if direct_match is not None:
|
| 52 |
-
planner_result["asset_id"] = direct_match.asset_id
|
| 53 |
-
planner_result["reasoning"] = "Direct asset match found from the user message."
|
| 54 |
-
elif payload.asset_id:
|
| 55 |
-
planner_result["asset_id"] = payload.asset_id
|
| 56 |
-
planner_result["reasoning"] = "Asset id was provided directly in request."
|
| 57 |
-
else:
|
| 58 |
-
planner_result = llm_service.plan_request(
|
| 59 |
-
user_question=payload.question,
|
| 60 |
-
asset_catalog_context=self._build_asset_catalog_context(candidates),
|
| 61 |
-
)
|
| 62 |
-
|
| 63 |
-
asset = self._resolve_asset(payload.asset_id, planner_result, payload.question, candidates, user_id=user_id)
|
| 64 |
-
|
| 65 |
-
# Guardrail: If user asked for 'coldroom' but we picked a 'tank', check if we have better candidates
|
| 66 |
-
if asset.asset_type == "tank" and "cold" in payload.question.lower() and "room" in payload.question.lower():
|
| 67 |
-
for c in candidates:
|
| 68 |
-
if c.asset_type == "cold_room":
|
| 69 |
-
asset = c
|
| 70 |
-
planner_result["reasoning"] = "User asked for coldrooms, overriding tank selection with a valid coldroom candidate."
|
| 71 |
-
break
|
| 72 |
-
|
| 73 |
-
analysis_name = payload.analysis_name or planner_result.get("analysis_name") or "historical_variance"
|
| 74 |
-
time_window = payload.time_window or self._parse_time_window(planner_result.get("time_window"))
|
| 75 |
-
explicit_time_window = self._extract_explicit_time_range_window(payload.question)
|
| 76 |
-
if explicit_time_window is not None:
|
| 77 |
-
started_at, ended_at, label = explicit_time_window
|
| 78 |
-
duration_hours = max(
|
| 79 |
-
1,
|
| 80 |
-
int(math.ceil((ended_at - started_at).total_seconds() / 3600)),
|
| 81 |
-
)
|
| 82 |
-
time_window = TimeWindow(
|
| 83 |
-
scope="historical",
|
| 84 |
-
value=duration_hours,
|
| 85 |
-
unit="hours",
|
| 86 |
-
label=label,
|
| 87 |
-
start_at=started_at.isoformat(),
|
| 88 |
-
end_at=ended_at.isoformat(),
|
| 89 |
-
)
|
| 90 |
-
elif (relative_day_window := self._extract_relative_day_window(payload.question)) is not None:
|
| 91 |
-
started_at, ended_at, label = relative_day_window
|
| 92 |
-
duration_hours = max(
|
| 93 |
-
1,
|
| 94 |
-
int(math.ceil((ended_at - started_at).total_seconds() / 3600)),
|
| 95 |
-
)
|
| 96 |
-
time_window = TimeWindow(
|
| 97 |
-
scope="historical",
|
| 98 |
-
value=duration_hours,
|
| 99 |
-
unit="hours",
|
| 100 |
-
label=label,
|
| 101 |
-
start_at=started_at.isoformat(),
|
| 102 |
-
end_at=ended_at.isoformat(),
|
| 103 |
-
)
|
| 104 |
-
elif (relative_duration_window := self._extract_relative_duration_window(payload.question)) is not None:
|
| 105 |
-
time_window = relative_duration_window
|
| 106 |
-
if explicit_time_window is None and (specific_date_window := self._extract_specific_date_window(payload.question)) is not None:
|
| 107 |
-
started_at, ended_at, label = specific_date_window
|
| 108 |
-
time_window = TimeWindow(
|
| 109 |
-
scope="historical",
|
| 110 |
-
value=1,
|
| 111 |
-
unit="days",
|
| 112 |
-
label=label,
|
| 113 |
-
start_at=started_at.isoformat(),
|
| 114 |
-
end_at=ended_at.isoformat(),
|
| 115 |
-
)
|
| 116 |
-
if payload.time_window is None and not self._question_mentions_time_window(payload.question):
|
| 117 |
-
time_window = self._default_analysis_time_window()
|
| 118 |
-
asset_context = build_asset_llm_context(asset)
|
| 119 |
-
query_window = self._build_query_window(time_window)
|
| 120 |
-
|
| 121 |
-
use_deterministic_sql = (
|
| 122 |
-
asset.asset_type in {"tank", "cold_room", "smoke_alarm"}
|
| 123 |
-
or (not asset.is_device and asset.child_count > 0)
|
| 124 |
-
)
|
| 125 |
-
sql_result = {"sql_query": "", "total_tokens": 0, "raw_response": ""}
|
| 126 |
-
if use_deterministic_sql:
|
| 127 |
-
sql_query = nl_to_sql_service.build_query(
|
| 128 |
-
asset=asset,
|
| 129 |
-
question=payload.question,
|
| 130 |
-
analysis_name=analysis_name,
|
| 131 |
-
time_window=time_window,
|
| 132 |
-
schema_name=payload.schema_name,
|
| 133 |
-
).query
|
| 134 |
-
else:
|
| 135 |
-
sql_result = llm_service.generate_sql(
|
| 136 |
-
user_question=payload.question,
|
| 137 |
-
asset_context=asset_context,
|
| 138 |
-
analysis_name=analysis_name,
|
| 139 |
-
time_window_label=time_window.label,
|
| 140 |
-
)
|
| 141 |
-
sql_query = nl_to_sql_service.normalize_generated_query(sql_result["sql_query"], asset)
|
| 142 |
-
|
| 143 |
-
warnings: list[str] = []
|
| 144 |
-
try:
|
| 145 |
-
validate_read_only_sql(sql_query)
|
| 146 |
-
except Exception:
|
| 147 |
-
fallback_sql = nl_to_sql_service.build_query(
|
| 148 |
-
asset=asset,
|
| 149 |
-
question=payload.question,
|
| 150 |
-
analysis_name=analysis_name,
|
| 151 |
-
time_window=time_window,
|
| 152 |
-
schema_name=payload.schema_name,
|
| 153 |
-
)
|
| 154 |
-
sql_query = fallback_sql.query
|
| 155 |
-
warnings.append("The LLM SQL was invalid, so a safe fallback SQL template was used.")
|
| 156 |
-
|
| 157 |
-
try:
|
| 158 |
-
rows = data_repository.execute_query(sql_query)
|
| 159 |
-
except DataQueryError as exc:
|
| 160 |
-
fallback_sql = nl_to_sql_service.build_query(
|
| 161 |
-
asset=asset,
|
| 162 |
-
question=payload.question,
|
| 163 |
-
analysis_name=analysis_name,
|
| 164 |
-
time_window=time_window,
|
| 165 |
-
schema_name=payload.schema_name,
|
| 166 |
-
)
|
| 167 |
-
|
| 168 |
-
sql_query = fallback_sql.query
|
| 169 |
-
warnings.append("The generated SQL failed at runtime, so a safe fallback query was used.")
|
| 170 |
-
try:
|
| 171 |
-
rows = data_repository.execute_query(sql_query)
|
| 172 |
-
except DataQueryError as retry_exc:
|
| 173 |
-
raise HTTPException(status_code=502, detail=str(retry_exc)) from retry_exc
|
| 174 |
-
rows = self._clip_rows_to_window(rows, time_window)
|
| 175 |
-
# Flatten rows to ensure nested sensor_data is usable by charting and deterministic tools
|
| 176 |
-
flattened_rows = []
|
| 177 |
-
for row in rows:
|
| 178 |
-
new_row = dict(row)
|
| 179 |
-
sd = row.get("sensor_data")
|
| 180 |
-
if isinstance(sd, dict):
|
| 181 |
-
for k, v in sd.items():
|
| 182 |
-
if k not in new_row:
|
| 183 |
-
new_row[k] = v
|
| 184 |
-
flattened_rows.append(new_row)
|
| 185 |
-
rows = flattened_rows
|
| 186 |
-
|
| 187 |
-
latest_row_time = self._latest_row_timestamp(rows)
|
| 188 |
-
query_window = self._apply_display_window_override(payload.question, time_window, query_window)
|
| 189 |
-
|
| 190 |
-
deterministic_summary = deterministic_analysis_service.summarize(asset, rows)
|
| 191 |
-
warnings.extend(list(deterministic_summary["warnings"]))
|
| 192 |
-
if latest_row_time is not None:
|
| 193 |
-
try:
|
| 194 |
-
request_end = datetime.fromisoformat(query_window["ended_at_iso"])
|
| 195 |
-
if latest_row_time.tzinfo is None and request_end.tzinfo is not None:
|
| 196 |
-
latest_row_time = latest_row_time.replace(tzinfo=request_end.tzinfo)
|
| 197 |
-
if latest_row_time < request_end:
|
| 198 |
-
diff = request_end - latest_row_time
|
| 199 |
-
if diff.total_seconds() > 3600:
|
| 200 |
-
hours = int(diff.total_seconds() // 3600)
|
| 201 |
-
warnings.append(
|
| 202 |
-
f"The data for {asset.name} appears to be stale ({hours} hours old). "
|
| 203 |
-
f"Latest telemetry available is from {latest_row_time.strftime('%d %b %Y, %I:%M %p')} (Asia/Karachi)."
|
| 204 |
-
)
|
| 205 |
-
else:
|
| 206 |
-
warnings.append(
|
| 207 |
-
f"Latest telemetry available for {asset.name} in this window is {latest_row_time.strftime('%d %b %Y, %I:%M %p')} (Asia/Karachi)."
|
| 208 |
-
)
|
| 209 |
-
except Exception:
|
| 210 |
-
pass
|
| 211 |
-
|
| 212 |
-
if asset.asset_type == "smoke_alarm":
|
| 213 |
-
final_analysis = {
|
| 214 |
-
"answer": self._build_smoke_alarm_answer(asset, time_window, deterministic_summary, rows),
|
| 215 |
-
"total_tokens": 0,
|
| 216 |
-
"raw_response": "",
|
| 217 |
-
"prompt_text": "",
|
| 218 |
-
}
|
| 219 |
-
else:
|
| 220 |
-
final_analysis = llm_service.analyze_result(
|
| 221 |
-
user_question=payload.question,
|
| 222 |
-
asset_context=asset_context,
|
| 223 |
-
sql_query=sql_query,
|
| 224 |
-
deterministic_findings=deterministic_summary,
|
| 225 |
-
rows=rows,
|
| 226 |
-
)
|
| 227 |
-
|
| 228 |
-
total_tokens = (
|
| 229 |
-
int(sql_result.get("total_tokens", 0))
|
| 230 |
-
+ int(final_analysis.get("total_tokens", 0))
|
| 231 |
-
)
|
| 232 |
-
|
| 233 |
-
plan = AnalysisPlan(
|
| 234 |
-
analysis_name=analysis_name,
|
| 235 |
-
reasoning=planner_result.get("reasoning", ""),
|
| 236 |
-
time_window=time_window,
|
| 237 |
-
query_started_at=query_window["started_at_iso"],
|
| 238 |
-
query_ended_at=query_window["ended_at_iso"],
|
| 239 |
-
query_window_label=query_window["label"],
|
| 240 |
-
planner_prompt=planner_result.get("prompt_text", ""),
|
| 241 |
-
sql_prompt=sql_result.get("prompt_text", ""),
|
| 242 |
-
analyst_prompt=final_analysis.get("prompt_text", ""),
|
| 243 |
-
sql_query=sql_query,
|
| 244 |
-
chart_hints=self._chart_hints_for_analysis(analysis_name),
|
| 245 |
-
warnings=warnings,
|
| 246 |
-
total_tokens=total_tokens,
|
| 247 |
-
llm_raw_response=final_analysis.get("raw_response", ""),
|
| 248 |
-
)
|
| 249 |
-
report = report_builder_service.build(
|
| 250 |
-
asset=asset,
|
| 251 |
-
plan=plan,
|
| 252 |
-
rows=rows,
|
| 253 |
-
summary_override=final_analysis.get("answer", ""),
|
| 254 |
-
)
|
| 255 |
-
trend_chart = trend_chart_service.build(asset, analysis_name, time_window, rows)
|
| 256 |
-
trend_charts = [trend_chart] if trend_chart else []
|
| 257 |
-
return AnalysisResponse(
|
| 258 |
-
asset=asset,
|
| 259 |
-
plan=plan,
|
| 260 |
-
report=report,
|
| 261 |
-
trend_chart=trend_chart,
|
| 262 |
-
trend_charts=trend_charts,
|
| 263 |
-
rows=rows,
|
| 264 |
-
)
|
| 265 |
-
|
| 266 |
-
def _build_smoke_alarm_answer(
|
| 267 |
-
self,
|
| 268 |
-
asset,
|
| 269 |
-
time_window: TimeWindow,
|
| 270 |
-
deterministic_summary: dict[str, object],
|
| 271 |
-
rows: list[dict],
|
| 272 |
-
) -> str:
|
| 273 |
-
findings = [str(item).strip() for item in deterministic_summary.get("findings", []) if str(item).strip()]
|
| 274 |
-
warnings = [str(item).strip() for item in deterministic_summary.get("warnings", []) if str(item).strip()]
|
| 275 |
-
|
| 276 |
-
if not rows:
|
| 277 |
-
lines = [
|
| 278 |
-
f"No smoke-alarm telemetry rows were found for {asset.name} in {time_window.label}.",
|
| 279 |
-
]
|
| 280 |
-
if warnings:
|
| 281 |
-
lines.append("")
|
| 282 |
-
lines.extend(f"- {item}" for item in warnings[:4])
|
| 283 |
-
return "\n".join(lines)
|
| 284 |
-
|
| 285 |
-
headline = findings[0] if findings else f"{asset.name} smoke-alarm telemetry was analyzed for {time_window.label}."
|
| 286 |
-
latest_states = [item for item in findings[1:] if ": " in item][:6]
|
| 287 |
-
other_findings = [item for item in findings[1:] if item not in latest_states][:4]
|
| 288 |
-
|
| 289 |
-
lines = [headline]
|
| 290 |
-
|
| 291 |
-
if other_findings:
|
| 292 |
-
lines.append("")
|
| 293 |
-
lines.append("Key points")
|
| 294 |
-
lines.extend(f"- {item}" for item in other_findings)
|
| 295 |
-
|
| 296 |
-
if latest_states:
|
| 297 |
-
lines.append("")
|
| 298 |
-
lines.append("Latest device states")
|
| 299 |
-
lines.extend(f"- {item}" for item in latest_states)
|
| 300 |
-
|
| 301 |
-
if warnings:
|
| 302 |
-
lines.append("")
|
| 303 |
-
lines.append("Attention items")
|
| 304 |
-
lines.extend(f"- {item}" for item in warnings[:4])
|
| 305 |
-
|
| 306 |
-
return "\n".join(lines)
|
| 307 |
-
|
| 308 |
-
def _apply_display_window_override(
|
| 309 |
-
self,
|
| 310 |
-
question: str,
|
| 311 |
-
time_window: TimeWindow,
|
| 312 |
-
query_window: dict[str, str],
|
| 313 |
-
) -> dict[str, str]:
|
| 314 |
-
text = question.lower()
|
| 315 |
-
# Keep window as-is unless user asked for "today".
|
| 316 |
-
if "today" not in text:
|
| 317 |
-
return query_window
|
| 318 |
-
if not time_window.start_at:
|
| 319 |
-
return query_window
|
| 320 |
-
start_at = self._parse_datetime(time_window.start_at)
|
| 321 |
-
if start_at is None:
|
| 322 |
-
return query_window
|
| 323 |
-
end_at = self._parse_datetime(query_window.get("ended_at_iso", ""))
|
| 324 |
-
if end_at is None:
|
| 325 |
-
return query_window
|
| 326 |
-
|
| 327 |
-
friendly_format = "%d %b %Y, %I:%M %p"
|
| 328 |
-
query_window["label"] = (
|
| 329 |
-
f"{start_at.strftime(friendly_format)} to "
|
| 330 |
-
f"{end_at.strftime(friendly_format)} "
|
| 331 |
-
f"(Asia/Karachi)"
|
| 332 |
-
)
|
| 333 |
-
return query_window
|
| 334 |
-
|
| 335 |
-
def _latest_row_timestamp(self, rows: list[dict]) -> datetime | None:
|
| 336 |
-
timestamp_keys = ("recorded_at", "timestamp", "local_time", "raw_time", "latest_time")
|
| 337 |
-
latest: datetime | None = None
|
| 338 |
-
for row in rows:
|
| 339 |
-
for key in timestamp_keys:
|
| 340 |
-
value = row.get(key)
|
| 341 |
-
if value is None:
|
| 342 |
-
continue
|
| 343 |
-
parsed = self._parse_datetime(value)
|
| 344 |
-
if parsed is None:
|
| 345 |
-
continue
|
| 346 |
-
if latest is None or parsed > latest:
|
| 347 |
-
latest = parsed
|
| 348 |
-
break
|
| 349 |
-
return latest
|
| 350 |
-
|
| 351 |
-
def _resolve_asset(
|
| 352 |
-
self,
|
| 353 |
-
requested_asset_id: str | None,
|
| 354 |
-
planner_result: dict,
|
| 355 |
-
question: str,
|
| 356 |
-
candidates: list,
|
| 357 |
-
user_id: str = "user_1",
|
| 358 |
-
) -> object:
|
| 359 |
-
if requested_asset_id:
|
| 360 |
-
asset = live_asset_registry.get_asset(requested_asset_id, user_id=user_id)
|
| 361 |
-
if asset is not None:
|
| 362 |
-
return asset
|
| 363 |
-
|
| 364 |
-
direct_match = live_asset_registry.best_direct_match(question, user_id=user_id)
|
| 365 |
-
if direct_match is not None:
|
| 366 |
-
return direct_match
|
| 367 |
-
|
| 368 |
-
planned_asset_id = planner_result.get("asset_id", "")
|
| 369 |
-
if planned_asset_id:
|
| 370 |
-
asset = live_asset_registry.get_asset(planned_asset_id, user_id=user_id)
|
| 371 |
-
if asset is not None:
|
| 372 |
-
return asset
|
| 373 |
-
|
| 374 |
-
available = list(dict.fromkeys(asset.name for asset in (candidates[:12] or live_asset_registry.list_assets(user_id)[:12])))
|
| 375 |
-
detail = "I could not identify the asset from your message."
|
| 376 |
-
if available:
|
| 377 |
-
detail += f" Available assets include: {', '.join(available)}."
|
| 378 |
-
raise HTTPException(status_code=422, detail=detail)
|
| 379 |
-
|
| 380 |
-
def _parse_time_window(self, raw_time_window: dict | None) -> TimeWindow:
|
| 381 |
-
if not raw_time_window:
|
| 382 |
-
return self._default_analysis_time_window()
|
| 383 |
-
try:
|
| 384 |
-
return TimeWindow(**raw_time_window)
|
| 385 |
-
except Exception:
|
| 386 |
-
return self._default_analysis_time_window()
|
| 387 |
-
|
| 388 |
-
def _default_analysis_time_window(self) -> TimeWindow:
|
| 389 |
-
return TimeWindow(scope="historical", value=3, unit="hours", label="last 3 hours")
|
| 390 |
-
|
| 391 |
-
def _question_mentions_time_window(self, question: str) -> bool:
|
| 392 |
-
text = question.lower()
|
| 393 |
-
if self._extract_explicit_time_range_window(question) is not None:
|
| 394 |
-
return True
|
| 395 |
-
if self._extract_relative_duration_window(question) is not None:
|
| 396 |
-
return True
|
| 397 |
-
if self._extract_relative_day_window(question) is not None:
|
| 398 |
-
return True
|
| 399 |
-
if self._extract_specific_date_window(question) is not None:
|
| 400 |
-
return True
|
| 401 |
-
|
| 402 |
-
time_words = (
|
| 403 |
-
"today",
|
| 404 |
-
"yesterday",
|
| 405 |
-
"tomorrow",
|
| 406 |
-
"last",
|
| 407 |
-
"past",
|
| 408 |
-
"previous",
|
| 409 |
-
"between",
|
| 410 |
-
"from",
|
| 411 |
-
"to",
|
| 412 |
-
"since",
|
| 413 |
-
"until",
|
| 414 |
-
"till",
|
| 415 |
-
"hour",
|
| 416 |
-
"hours",
|
| 417 |
-
"minute",
|
| 418 |
-
"minutes",
|
| 419 |
-
"day",
|
| 420 |
-
"days",
|
| 421 |
-
"week",
|
| 422 |
-
"weeks",
|
| 423 |
-
"month",
|
| 424 |
-
"months",
|
| 425 |
-
)
|
| 426 |
-
return any(word in text for word in time_words)
|
| 427 |
-
|
| 428 |
-
def _build_query_window(self, time_window: TimeWindow) -> dict[str, str]:
|
| 429 |
-
timezone = ZoneInfo("Asia/Karachi")
|
| 430 |
-
if time_window.start_at and time_window.end_at:
|
| 431 |
-
started_at = datetime.fromisoformat(time_window.start_at)
|
| 432 |
-
ended_at = datetime.fromisoformat(time_window.end_at)
|
| 433 |
-
friendly_format = "%d %b %Y, %I:%M %p"
|
| 434 |
-
return {
|
| 435 |
-
"started_at_iso": started_at.isoformat(),
|
| 436 |
-
"ended_at_iso": ended_at.isoformat(),
|
| 437 |
-
"label": (
|
| 438 |
-
f"{started_at.strftime(friendly_format)} to "
|
| 439 |
-
f"{ended_at.strftime(friendly_format)} (Asia/Karachi)"
|
| 440 |
-
),
|
| 441 |
-
}
|
| 442 |
-
|
| 443 |
-
ended_at = datetime.now(timezone)
|
| 444 |
-
|
| 445 |
-
unit_to_delta = {
|
| 446 |
-
"minutes": timedelta(minutes=time_window.value),
|
| 447 |
-
"hours": timedelta(hours=time_window.value),
|
| 448 |
-
"days": timedelta(days=time_window.value),
|
| 449 |
-
"weeks": timedelta(weeks=time_window.value),
|
| 450 |
-
"months": timedelta(days=30 * time_window.value),
|
| 451 |
-
}
|
| 452 |
-
started_at = ended_at - unit_to_delta.get(time_window.unit, timedelta(hours=3))
|
| 453 |
-
friendly_format = "%d %b %Y, %I:%M %p"
|
| 454 |
-
|
| 455 |
-
return {
|
| 456 |
-
"started_at_iso": started_at.isoformat(),
|
| 457 |
-
"ended_at_iso": ended_at.isoformat(),
|
| 458 |
-
"label": (
|
| 459 |
-
f"{started_at.strftime(friendly_format)} to "
|
| 460 |
-
f"{ended_at.strftime(friendly_format)} (Asia/Karachi)"
|
| 461 |
-
),
|
| 462 |
-
}
|
| 463 |
-
|
| 464 |
-
def _extract_specific_date_window(self, question: str) -> tuple[datetime, datetime, str] | None:
|
| 465 |
-
timezone = ZoneInfo("Asia/Karachi")
|
| 466 |
-
patterns = [
|
| 467 |
-
r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b",
|
| 468 |
-
r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b",
|
| 469 |
-
r"\b(\d{1,2})-(\d{1,2})-(\d{4})\b",
|
| 470 |
-
]
|
| 471 |
-
|
| 472 |
-
for pattern in patterns:
|
| 473 |
-
match = re.search(pattern, question)
|
| 474 |
-
if not match:
|
| 475 |
-
continue
|
| 476 |
-
groups = match.groups()
|
| 477 |
-
try:
|
| 478 |
-
if pattern.startswith(r"\b(\d{4})"):
|
| 479 |
-
year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
|
| 480 |
-
else:
|
| 481 |
-
day, month, year = int(groups[0]), int(groups[1]), int(groups[2])
|
| 482 |
-
anchor_date = datetime(year, month, day, 6, 0, 0, tzinfo=timezone)
|
| 483 |
-
end_date = anchor_date + timedelta(days=1)
|
| 484 |
-
label = (
|
| 485 |
-
f"{anchor_date.strftime('%d %b %Y')} 06:00 AM to "
|
| 486 |
-
f"{end_date.strftime('%d %b %Y')} 06:00 AM"
|
| 487 |
-
)
|
| 488 |
-
return anchor_date, end_date, label
|
| 489 |
-
except ValueError:
|
| 490 |
-
continue
|
| 491 |
-
|
| 492 |
-
month_name_patterns = [
|
| 493 |
-
r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\s*,?\s*(\d{4})\b",
|
| 494 |
-
r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\s*,?\s*(\d{4})\b",
|
| 495 |
-
r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\b",
|
| 496 |
-
r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\b",
|
| 497 |
-
]
|
| 498 |
-
month_map = {
|
| 499 |
-
"jan": 1, "january": 1,
|
| 500 |
-
"feb": 2, "february": 2,
|
| 501 |
-
"mar": 3, "march": 3,
|
| 502 |
-
"apr": 4, "april": 4,
|
| 503 |
-
"may": 5,
|
| 504 |
-
"jun": 6, "june": 6,
|
| 505 |
-
"jul": 7, "july": 7,
|
| 506 |
-
"aug": 8, "august": 8,
|
| 507 |
-
"sep": 9, "sept": 9, "september": 9,
|
| 508 |
-
"oct": 10, "october": 10,
|
| 509 |
-
"nov": 11, "november": 11,
|
| 510 |
-
"dec": 12, "decebmber": 12, "december": 12,
|
| 511 |
-
}
|
| 512 |
-
now_local = datetime.now(timezone)
|
| 513 |
-
for pattern in month_name_patterns:
|
| 514 |
-
match = re.search(pattern, question, flags=re.IGNORECASE)
|
| 515 |
-
if not match:
|
| 516 |
-
continue
|
| 517 |
-
groups = match.groups()
|
| 518 |
-
try:
|
| 519 |
-
if pattern.startswith(r"\b(\d"):
|
| 520 |
-
day = int(groups[0])
|
| 521 |
-
month = month_map.get(groups[1].lower())
|
| 522 |
-
year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year
|
| 523 |
-
else:
|
| 524 |
-
month = month_map.get(groups[0].lower())
|
| 525 |
-
day = int(groups[1])
|
| 526 |
-
year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year
|
| 527 |
-
if month is None:
|
| 528 |
-
continue
|
| 529 |
-
anchor_date = datetime(year, month, day, 6, 0, 0, tzinfo=timezone)
|
| 530 |
-
end_date = anchor_date + timedelta(days=1)
|
| 531 |
-
label = (
|
| 532 |
-
f"{anchor_date.strftime('%d %b %Y')} 06:00 AM to "
|
| 533 |
-
f"{end_date.strftime('%d %b %Y')} 06:00 AM"
|
| 534 |
-
)
|
| 535 |
-
return anchor_date, end_date, label
|
| 536 |
-
except ValueError:
|
| 537 |
-
continue
|
| 538 |
-
|
| 539 |
-
ordinal_only = re.search(r"\b(\d{1,2})(?:st|nd|rd|th)\b", question, flags=re.IGNORECASE)
|
| 540 |
-
if ordinal_only:
|
| 541 |
-
day = int(ordinal_only.group(1))
|
| 542 |
-
year = now_local.year
|
| 543 |
-
month = now_local.month
|
| 544 |
-
if day > now_local.day:
|
| 545 |
-
if month == 1:
|
| 546 |
-
month = 12
|
| 547 |
-
year -= 1
|
| 548 |
-
else:
|
| 549 |
-
month -= 1
|
| 550 |
-
try:
|
| 551 |
-
anchor_date = datetime(year, month, day, 6, 0, 0, tzinfo=timezone)
|
| 552 |
-
end_date = anchor_date + timedelta(days=1)
|
| 553 |
-
label = (
|
| 554 |
-
f"{anchor_date.strftime('%d %b %Y')} 06:00 AM to "
|
| 555 |
-
f"{end_date.strftime('%d %b %Y')} 06:00 AM"
|
| 556 |
-
)
|
| 557 |
-
return anchor_date, end_date, label
|
| 558 |
-
except ValueError:
|
| 559 |
-
return None
|
| 560 |
-
return None
|
| 561 |
-
|
| 562 |
-
def _extract_relative_duration_window(self, question: str) -> TimeWindow | None:
|
| 563 |
-
lowered = question.lower()
|
| 564 |
-
|
| 565 |
-
unit_map = {
|
| 566 |
-
"h": "hours",
|
| 567 |
-
"hr": "hours",
|
| 568 |
-
"hrs": "hours",
|
| 569 |
-
"hour": "hours",
|
| 570 |
-
"hours": "hours",
|
| 571 |
-
"d": "days",
|
| 572 |
-
"day": "days",
|
| 573 |
-
"days": "days",
|
| 574 |
-
"w": "weeks",
|
| 575 |
-
"wk": "weeks",
|
| 576 |
-
"wks": "weeks",
|
| 577 |
-
"week": "weeks",
|
| 578 |
-
"weeks": "weeks",
|
| 579 |
-
"m": "months",
|
| 580 |
-
"mo": "months",
|
| 581 |
-
"mon": "months",
|
| 582 |
-
"month": "months",
|
| 583 |
-
"months": "months",
|
| 584 |
-
"min": "minutes",
|
| 585 |
-
"mins": "minutes",
|
| 586 |
-
"minute": "minutes",
|
| 587 |
-
"minutes": "minutes",
|
| 588 |
-
}
|
| 589 |
-
|
| 590 |
-
def build_window(value: int, raw_unit: str) -> TimeWindow | None:
|
| 591 |
-
unit = unit_map.get(raw_unit.lower())
|
| 592 |
-
if unit is None:
|
| 593 |
-
return None
|
| 594 |
-
return TimeWindow(
|
| 595 |
-
scope="historical",
|
| 596 |
-
value=value,
|
| 597 |
-
unit=unit,
|
| 598 |
-
label=f"last {value} {unit}",
|
| 599 |
-
)
|
| 600 |
-
|
| 601 |
-
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"
|
| 602 |
-
match = re.search(pattern, lowered)
|
| 603 |
-
if match:
|
| 604 |
-
built = build_window(int(match.group(1)), match.group(2))
|
| 605 |
-
if built is not None:
|
| 606 |
-
return built
|
| 607 |
-
|
| 608 |
-
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"
|
| 609 |
-
match_single = re.search(pattern_single, lowered)
|
| 610 |
-
if match_single:
|
| 611 |
-
built = build_window(1, match_single.group(1))
|
| 612 |
-
if built is not None:
|
| 613 |
-
return built
|
| 614 |
-
|
| 615 |
-
pattern_bare = r"\b(\d+)\s*(h|hr|hrs?|hours?|d|days?|w|wk|wks|weeks?|m|mo|mon|months?|min|mins?|minutes?)\b"
|
| 616 |
-
match_bare = re.search(pattern_bare, lowered)
|
| 617 |
-
if match_bare:
|
| 618 |
-
built = build_window(int(match_bare.group(1)), match_bare.group(2))
|
| 619 |
-
if built is not None:
|
| 620 |
-
return built
|
| 621 |
-
|
| 622 |
-
return None
|
| 623 |
-
|
| 624 |
-
def _extract_explicit_time_range_window(self, question: str) -> tuple[datetime, datetime, str] | None:
|
| 625 |
-
timezone = ZoneInfo("Asia/Karachi")
|
| 626 |
-
lowered = question.lower()
|
| 627 |
-
range_match = re.search(
|
| 628 |
-
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",
|
| 629 |
-
lowered,
|
| 630 |
-
flags=re.IGNORECASE,
|
| 631 |
-
)
|
| 632 |
-
if not range_match:
|
| 633 |
-
return None
|
| 634 |
-
|
| 635 |
-
start_hm = self._parse_clock_token(range_match.group(1))
|
| 636 |
-
end_hm = self._parse_clock_token(range_match.group(2))
|
| 637 |
-
if start_hm is None or end_hm is None:
|
| 638 |
-
return None
|
| 639 |
-
|
| 640 |
-
anchor = self._extract_anchor_date(question, timezone) or datetime.now(timezone).date()
|
| 641 |
-
started_at = datetime(anchor.year, anchor.month, anchor.day, start_hm[0], start_hm[1], tzinfo=timezone)
|
| 642 |
-
ended_at = datetime(anchor.year, anchor.month, anchor.day, end_hm[0], end_hm[1], tzinfo=timezone)
|
| 643 |
-
if ended_at <= started_at:
|
| 644 |
-
ended_at += timedelta(days=1)
|
| 645 |
-
|
| 646 |
-
friendly_format = "%d %b %Y, %I:%M %p"
|
| 647 |
-
label = f"{started_at.strftime(friendly_format)} to {ended_at.strftime(friendly_format)} (Asia/Karachi)"
|
| 648 |
-
return started_at, ended_at, label
|
| 649 |
-
|
| 650 |
-
def _parse_clock_token(self, token: str) -> tuple[int, int] | None:
|
| 651 |
-
match = re.match(r"^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*$", token.strip(), flags=re.IGNORECASE)
|
| 652 |
-
if not match:
|
| 653 |
-
return None
|
| 654 |
-
hour = int(match.group(1))
|
| 655 |
-
minute = int(match.group(2) or "0")
|
| 656 |
-
meridian = match.group(3).lower()
|
| 657 |
-
if hour < 1 or hour > 12 or minute < 0 or minute > 59:
|
| 658 |
-
return None
|
| 659 |
-
if meridian == "am":
|
| 660 |
-
hour = 0 if hour == 12 else hour
|
| 661 |
-
else:
|
| 662 |
-
hour = 12 if hour == 12 else hour + 12
|
| 663 |
-
return hour, minute
|
| 664 |
-
|
| 665 |
-
def _extract_anchor_date(self, question: str, timezone: ZoneInfo) -> date | None:
|
| 666 |
-
patterns = [
|
| 667 |
-
r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b",
|
| 668 |
-
r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b",
|
| 669 |
-
r"\b(\d{1,2})-(\d{1,2})-(\d{4})\b",
|
| 670 |
-
]
|
| 671 |
-
|
| 672 |
-
for pattern in patterns:
|
| 673 |
-
match = re.search(pattern, question)
|
| 674 |
-
if not match:
|
| 675 |
-
continue
|
| 676 |
-
groups = match.groups()
|
| 677 |
-
try:
|
| 678 |
-
if pattern.startswith(r"\b(\d{4})"):
|
| 679 |
-
year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
|
| 680 |
-
else:
|
| 681 |
-
day, month, year = int(groups[0]), int(groups[1]), int(groups[2])
|
| 682 |
-
return date(year, month, day)
|
| 683 |
-
except ValueError:
|
| 684 |
-
continue
|
| 685 |
-
|
| 686 |
-
month_name_patterns = [
|
| 687 |
-
r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\s*,?\s*(\d{4})\b",
|
| 688 |
-
r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\s*,?\s*(\d{4})\b",
|
| 689 |
-
r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\b",
|
| 690 |
-
r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\b",
|
| 691 |
-
]
|
| 692 |
-
month_map = {
|
| 693 |
-
"jan": 1, "january": 1,
|
| 694 |
-
"feb": 2, "february": 2,
|
| 695 |
-
"mar": 3, "march": 3,
|
| 696 |
-
"apr": 4, "april": 4,
|
| 697 |
-
"may": 5,
|
| 698 |
-
"jun": 6, "june": 6,
|
| 699 |
-
"jul": 7, "july": 7,
|
| 700 |
-
"aug": 8, "august": 8,
|
| 701 |
-
"sep": 9, "sept": 9, "september": 9,
|
| 702 |
-
"oct": 10, "october": 10,
|
| 703 |
-
"nov": 11, "november": 11,
|
| 704 |
-
"dec": 12, "decebmber": 12, "december": 12,
|
| 705 |
-
}
|
| 706 |
-
now_local = datetime.now(timezone)
|
| 707 |
-
for pattern in month_name_patterns:
|
| 708 |
-
match = re.search(pattern, question, flags=re.IGNORECASE)
|
| 709 |
-
if not match:
|
| 710 |
-
continue
|
| 711 |
-
groups = match.groups()
|
| 712 |
-
try:
|
| 713 |
-
if pattern.startswith(r"\b(\d"):
|
| 714 |
-
day = int(groups[0])
|
| 715 |
-
month = month_map.get(groups[1].lower())
|
| 716 |
-
year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year
|
| 717 |
-
else:
|
| 718 |
-
month = month_map.get(groups[0].lower())
|
| 719 |
-
day = int(groups[1])
|
| 720 |
-
year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year
|
| 721 |
-
if month is None:
|
| 722 |
-
continue
|
| 723 |
-
return date(year, month, day)
|
| 724 |
-
except ValueError:
|
| 725 |
-
continue
|
| 726 |
-
|
| 727 |
-
ordinal_only = re.search(r"\b(\d{1,2})(?:st|nd|rd|th)\b", question, flags=re.IGNORECASE)
|
| 728 |
-
if ordinal_only:
|
| 729 |
-
day = int(ordinal_only.group(1))
|
| 730 |
-
year = now_local.year
|
| 731 |
-
month = now_local.month
|
| 732 |
-
if day > now_local.day:
|
| 733 |
-
if month == 1:
|
| 734 |
-
month = 12
|
| 735 |
-
year -= 1
|
| 736 |
-
else:
|
| 737 |
-
month -= 1
|
| 738 |
-
try:
|
| 739 |
-
return date(year, month, day)
|
| 740 |
-
except ValueError:
|
| 741 |
-
return None
|
| 742 |
-
return None
|
| 743 |
-
|
| 744 |
-
def _extract_relative_day_window(self, question: str) -> tuple[datetime, datetime, str] | None:
|
| 745 |
-
text = question.lower()
|
| 746 |
-
timezone = ZoneInfo("Asia/Karachi")
|
| 747 |
-
now_local = datetime.now(timezone)
|
| 748 |
-
|
| 749 |
-
if "today" in text or "scheduled" in text:
|
| 750 |
-
shift_start = now_local.replace(hour=6, minute=0, second=0, microsecond=0)
|
| 751 |
-
if now_local < shift_start:
|
| 752 |
-
shift_start = shift_start - timedelta(days=1)
|
| 753 |
-
label = (
|
| 754 |
-
f"{shift_start.strftime('%d %b %Y')} 06:00 AM to "
|
| 755 |
-
f"{now_local.strftime('%d %b %Y %I:%M %p')}"
|
| 756 |
-
)
|
| 757 |
-
return shift_start, now_local, label
|
| 758 |
-
|
| 759 |
-
if "yesterday" in text:
|
| 760 |
-
today_shift_start = now_local.replace(hour=6, minute=0, second=0, microsecond=0)
|
| 761 |
-
if now_local < today_shift_start:
|
| 762 |
-
today_shift_start = today_shift_start - timedelta(days=1)
|
| 763 |
-
yesterday_shift_start = today_shift_start - timedelta(days=1)
|
| 764 |
-
label = (
|
| 765 |
-
f"{yesterday_shift_start.strftime('%d %b %Y')} 06:00 AM to "
|
| 766 |
-
f"{today_shift_start.strftime('%d %b %Y')} 06:00 AM"
|
| 767 |
-
)
|
| 768 |
-
return yesterday_shift_start, today_shift_start, label
|
| 769 |
-
|
| 770 |
-
return None
|
| 771 |
-
|
| 772 |
-
def _clip_rows_to_window(self, rows: list[dict], time_window: TimeWindow) -> list[dict]:
|
| 773 |
-
if not rows or not time_window.start_at or not time_window.end_at:
|
| 774 |
-
return rows
|
| 775 |
-
try:
|
| 776 |
-
start_dt = datetime.fromisoformat(time_window.start_at)
|
| 777 |
-
end_dt = datetime.fromisoformat(time_window.end_at)
|
| 778 |
-
except ValueError:
|
| 779 |
-
return rows
|
| 780 |
-
|
| 781 |
-
timestamp_keys = ("recorded_at", "timestamp", "local_time", "raw_time")
|
| 782 |
-
filtered: list[dict] = []
|
| 783 |
-
for row in rows:
|
| 784 |
-
row_ts = None
|
| 785 |
-
for key in timestamp_keys:
|
| 786 |
-
value = row.get(key)
|
| 787 |
-
if value is None:
|
| 788 |
-
continue
|
| 789 |
-
row_ts = self._parse_datetime(value)
|
| 790 |
-
if row_ts is not None:
|
| 791 |
-
break
|
| 792 |
-
if row_ts is None:
|
| 793 |
-
filtered.append(row)
|
| 794 |
-
continue
|
| 795 |
-
|
| 796 |
-
if row_ts.tzinfo is None and start_dt.tzinfo is not None:
|
| 797 |
-
row_ts = row_ts.replace(tzinfo=start_dt.tzinfo)
|
| 798 |
-
if start_dt <= row_ts < end_dt:
|
| 799 |
-
filtered.append(row)
|
| 800 |
-
return filtered
|
| 801 |
-
|
| 802 |
-
def _parse_datetime(self, value: object) -> datetime | None:
|
| 803 |
-
if isinstance(value, datetime):
|
| 804 |
-
return value
|
| 805 |
-
text = str(value).strip()
|
| 806 |
-
if not text:
|
| 807 |
-
return None
|
| 808 |
-
try:
|
| 809 |
-
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
| 810 |
-
except ValueError:
|
| 811 |
-
pass
|
| 812 |
-
for fmt in (
|
| 813 |
-
"%d %b %Y, %I:%M:%S %p",
|
| 814 |
-
"%d %b %Y, %I:%M %p",
|
| 815 |
-
"%d-%m-%Y %I:%M %p",
|
| 816 |
-
"%Y-%m-%d %H:%M:%S",
|
| 817 |
-
"%Y-%m-%d %H:%M",
|
| 818 |
-
):
|
| 819 |
-
try:
|
| 820 |
-
return datetime.strptime(text, fmt)
|
| 821 |
-
except ValueError:
|
| 822 |
-
continue
|
| 823 |
-
return None
|
| 824 |
-
|
| 825 |
-
def _default_planner_result(self) -> dict:
|
| 826 |
-
return {
|
| 827 |
-
"asset_id": "",
|
| 828 |
-
"analysis_name": "live_status",
|
| 829 |
-
"time_window": {
|
| 830 |
-
"scope": "historical",
|
| 831 |
-
"value": 3,
|
| 832 |
-
"unit": "hours",
|
| 833 |
-
"label": "last 3 hours",
|
| 834 |
-
},
|
| 835 |
-
"reasoning": "Planner bypassed.",
|
| 836 |
-
"prompt_text": "[BYPASSED]",
|
| 837 |
-
}
|
| 838 |
-
|
| 839 |
-
def _build_asset_catalog_context(self, candidates: list) -> str:
|
| 840 |
-
shortlisted = candidates[:4] if candidates else live_asset_registry.list_assets()[:5]
|
| 841 |
-
entries: list[str] = []
|
| 842 |
-
total_chars = 0
|
| 843 |
-
max_chars = 2400
|
| 844 |
-
for asset in shortlisted:
|
| 845 |
-
aliases = [
|
| 846 |
-
alias for alias in asset.lookup_names
|
| 847 |
-
if alias and alias.lower() != asset.name.lower()
|
| 848 |
-
][:2]
|
| 849 |
-
entry = json.dumps(
|
| 850 |
-
{
|
| 851 |
-
"asset_id": asset.asset_id,
|
| 852 |
-
"name": asset.name,
|
| 853 |
-
"aliases": aliases,
|
| 854 |
-
"type": asset.asset_type,
|
| 855 |
-
"parent": asset.parent_name or "",
|
| 856 |
-
"child_count": asset.child_count,
|
| 857 |
-
"has_live_data": asset.has_live_data,
|
| 858 |
-
"data_keys": asset.data_keys[:4],
|
| 859 |
-
"supported_analyses": asset.supported_analyses[:3],
|
| 860 |
-
}
|
| 861 |
-
)
|
| 862 |
-
if total_chars + len(entry) > max_chars:
|
| 863 |
-
break
|
| 864 |
-
entries.append(entry)
|
| 865 |
-
total_chars += len(entry)
|
| 866 |
-
return "\n".join(entries)
|
| 867 |
-
|
| 868 |
-
def _chart_hints_for_analysis(self, analysis_name: str) -> list[str]:
|
| 869 |
-
mapping = {
|
| 870 |
-
"cooling_cycle_analysis": ["line_chart", "event_markers"],
|
| 871 |
-
"consumption_analysis": ["line_chart", "duration_summary", "event_markers"],
|
| 872 |
-
"live_status": ["stat_cards"],
|
| 873 |
-
"trend_analysis": ["line_chart"],
|
| 874 |
-
"historical_variance": ["line_chart", "box_plot"],
|
| 875 |
-
"throughput_utilization": ["bar_chart", "line_chart"],
|
| 876 |
-
"inventory_level_analysis": ["line_chart", "threshold_bands"],
|
| 877 |
-
"anomaly_detection": ["line_chart", "anomaly_table"],
|
| 878 |
-
}
|
| 879 |
-
return mapping.get(analysis_name, ["line_chart"])
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
analysis_agent_service = AnalysisAgentService()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/services/asset_analysis_instructions.py
DELETED
|
@@ -1,178 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from agents.analysis.backend.app.core.config import get_settings
|
| 4 |
-
from agents.analysis.backend.app.models.schemas import AssetProfile
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
ASSET_TYPE_ANALYSIS_GUIDANCE = {
|
| 8 |
-
"cold_room": """
|
| 9 |
-
For cold rooms, prioritize:
|
| 10 |
-
- live device health and payload freshness
|
| 11 |
-
- current temperature and humidity
|
| 12 |
-
- acceptable temperature band compliance
|
| 13 |
-
- historical drift, highs, lows, and instability
|
| 14 |
-
- cooling cycle estimation and repeated threshold breaches
|
| 15 |
-
- consumption analysis from temperature history when the asset belongs to T & H Monitoring
|
| 16 |
-
|
| 17 |
-
Preferred analysis choice:
|
| 18 |
-
- if user asks about live/current/status -> live_status
|
| 19 |
-
- if the asset is in T & H Monitoring and the user asks for a time window, prefer consumption_analysis
|
| 20 |
-
- if user asks about temperature pattern, cooling, stability -> cooling_cycle_analysis
|
| 21 |
-
- if user asks for previous day/week/month comparison -> historical_variance
|
| 22 |
-
- if user asks about abnormal behavior -> anomaly_detection
|
| 23 |
-
""".strip(),
|
| 24 |
-
"filling_machine": """
|
| 25 |
-
For filling machines, prioritize:
|
| 26 |
-
- current production counters
|
| 27 |
-
- per-shift throughput
|
| 28 |
-
- stale payload detection
|
| 29 |
-
- production movement over the requested time window
|
| 30 |
-
- identifying flat output, drops, or sudden spikes
|
| 31 |
-
|
| 32 |
-
Preferred analysis choice:
|
| 33 |
-
- if user asks about live/current/status -> live_status
|
| 34 |
-
- if user asks about production, throughput, utilization -> throughput_utilization
|
| 35 |
-
- if user asks about previous windows or comparisons -> historical_variance
|
| 36 |
-
- if user asks about unusual behavior -> anomaly_detection
|
| 37 |
-
""".strip(),
|
| 38 |
-
"tank": """
|
| 39 |
-
For tanks, prioritize:
|
| 40 |
-
- current level
|
| 41 |
-
- short-term and long-term rate of change
|
| 42 |
-
- refill and depletion pattern
|
| 43 |
-
- sharp drops and low-level risk
|
| 44 |
-
- stale payload detection
|
| 45 |
-
|
| 46 |
-
Preferred analysis choice:
|
| 47 |
-
- if user asks about live/current/status -> live_status
|
| 48 |
-
- if user asks about level movement or stock behavior -> inventory_level_analysis
|
| 49 |
-
- if user asks for historical movement -> trend_analysis
|
| 50 |
-
- if user asks about unusual drops -> anomaly_detection
|
| 51 |
-
""".strip(),
|
| 52 |
-
"smoke_alarm": """
|
| 53 |
-
For smoke alarm systems, prioritize:
|
| 54 |
-
- Enlisting ALL assets and ALL their attributes in a concise summary.
|
| 55 |
-
- 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.
|
| 56 |
-
- If all are okay, keep the per-asset summary brief (one line per asset).
|
| 57 |
-
- If smoke, fire, fault, or low-battery is detected, provide the detailed breakdown immediately for that specific asset.
|
| 58 |
-
- Highlight smoke/fire detection events prominently.
|
| 59 |
-
- Ensure health status, battery, temperature, and humidity are mentioned for every device.
|
| 60 |
-
|
| 61 |
-
Preferred analysis choice:
|
| 62 |
-
- if user asks about live/current/status -> live_status
|
| 63 |
-
- if user asks whether smoke/fire happened -> anomaly_detection
|
| 64 |
-
- if user asks about a category/group -> group_status
|
| 65 |
-
""".strip(),
|
| 66 |
-
"energy_meter": """
|
| 67 |
-
For energy meters, prioritize:
|
| 68 |
-
- overall power and energy consumption
|
| 69 |
-
- peak usage identification
|
| 70 |
-
- breaking down consumption by child meters
|
| 71 |
-
- power factor anomalies
|
| 72 |
-
- voltage or current imbalances
|
| 73 |
-
|
| 74 |
-
Preferred analysis choice:
|
| 75 |
-
- if user asks about live/current/status -> live_status
|
| 76 |
-
- if user asks about consumption, peak usage, or breakdown -> consumption_analysis
|
| 77 |
-
- if user asks about historical trends -> trend_analysis
|
| 78 |
-
- if user asks about a category/group -> group_status
|
| 79 |
-
""".strip(),
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
ASSET_NAME_OVERRIDES = {
|
| 84 |
-
"Cold Room 1": """
|
| 85 |
-
This cold room should be treated as a temperature-preservation asset.
|
| 86 |
-
The final answer should clearly mention whether it is stable, outside band,
|
| 87 |
-
or stale/offline, and whether temperature movement suggests a cooling issue.
|
| 88 |
-
""".strip(),
|
| 89 |
-
"Filling Machine 1": """
|
| 90 |
-
This machine should be treated as a throughput asset.
|
| 91 |
-
The final answer should clearly mention total count movement, strongest shift,
|
| 92 |
-
and whether the payload looks stale or flat.
|
| 93 |
-
""".strip(),
|
| 94 |
-
"Oil Tank 1": """
|
| 95 |
-
This tank should be treated as an inventory asset.
|
| 96 |
-
The final answer should clearly mention current level, whether the trend is falling,
|
| 97 |
-
and whether the level looks risky.
|
| 98 |
-
""".strip(),
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def build_asset_llm_context(asset: AssetProfile) -> str:
|
| 103 |
-
parts = [
|
| 104 |
-
f"Asset selected from chat: {asset.name}",
|
| 105 |
-
f"Asset type: {asset.asset_type}",
|
| 106 |
-
f"Is device with sensor data: {asset.is_device}",
|
| 107 |
-
f"Parent asset: {asset.parent_name or 'None'}",
|
| 108 |
-
f"Direct child count: {asset.child_count}",
|
| 109 |
-
f"Asset description: {asset.description}",
|
| 110 |
-
f"Lookup aliases: {', '.join(asset.lookup_names) if asset.lookup_names else asset.name}",
|
| 111 |
-
f"Supported analyses: {', '.join(asset.supported_analyses)}",
|
| 112 |
-
f"Important metrics: {', '.join(asset.metrics)}",
|
| 113 |
-
f"Attribute keys: {', '.join(asset.attribute_keys[:20]) if asset.attribute_keys else 'None'}",
|
| 114 |
-
(
|
| 115 |
-
"Exact telemetry keys: "
|
| 116 |
-
f"temperature={asset.temperature_keys}, "
|
| 117 |
-
f"humidity={asset.humidity_keys}, "
|
| 118 |
-
f"level={asset.level_keys}, "
|
| 119 |
-
f"production={asset.production_keys}"
|
| 120 |
-
),
|
| 121 |
-
(
|
| 122 |
-
"OpenRemote SQL facts: "
|
| 123 |
-
"join openremote.asset_datapoint to openremote.asset using ad.entity_id = a.id, "
|
| 124 |
-
"telemetry is usually under ad.value JSON, "
|
| 125 |
-
"multiple attributes can contain JSON sensor payloads, "
|
| 126 |
-
"the analysis should inspect all relevant JSON-carrying attributes unless the user narrows the request, "
|
| 127 |
-
"historical data should be read from asset datapoints, "
|
| 128 |
-
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')."
|
| 129 |
-
),
|
| 130 |
-
(
|
| 131 |
-
"Device vs category rule: only assets whose attributes contain keys other than notes and location "
|
| 132 |
-
"should be treated as real sensor devices. Parent/group assets should be analyzed via their child assets."
|
| 133 |
-
),
|
| 134 |
-
(
|
| 135 |
-
f"Nominal thresholds: {asset.nominal_range if asset.nominal_range else {'min_temperature': 5.0, 'max_temperature': 45.0}}. "
|
| 136 |
-
"Use these thresholds for compliance checks and avoid introducing any unstated default range."
|
| 137 |
-
),
|
| 138 |
-
]
|
| 139 |
-
|
| 140 |
-
if asset.asset_type == "tank":
|
| 141 |
-
parts.append(
|
| 142 |
-
"Tank rule: for tank-related devices, the actual physical liquid height is MaxFt - TankOilLevelInFeet001. "
|
| 143 |
-
"These values can come from two different attributes, so the analysis must combine them before drawing conclusions. "
|
| 144 |
-
"The final analysis should prioritize showing the percentage filled (e.g., 75% filled) rather than just the height, "
|
| 145 |
-
"using the formula (liquid height / MaxFt) * 100. "
|
| 146 |
-
"Do not explain how you calculated the level or percentage to the user."
|
| 147 |
-
)
|
| 148 |
-
|
| 149 |
-
if "t & h monitoring" in f"{asset.name} {asset.parent_name}".lower():
|
| 150 |
-
parts.append(
|
| 151 |
-
"T & H Monitoring consumption rule: determine cold-room ON/OFF state from temperature trend rather than a direct signal. "
|
| 152 |
-
"A cooling cycle starts at the last peak before temperature begins decreasing and later drops below 20C. "
|
| 153 |
-
"The cycle stays ON while temperature remains below 20C and ends at the valley before temperature rises back to 20C or above."
|
| 154 |
-
"Sum all such cycle durations across the requested window, clip any cycle that began before the shift to 6:00 AM, "
|
| 155 |
-
"and if a cycle is still active at the end of the data, count it up to the latest timestamp."
|
| 156 |
-
)
|
| 157 |
-
|
| 158 |
-
if asset.asset_type == "smoke_alarm":
|
| 159 |
-
parts.append(
|
| 160 |
-
"Smoke-alarm rule: inspect every relevant JSON-carrying attribute for each end device. "
|
| 161 |
-
"Treat warn='warn' as smoke/fire detected, warn='mute' as alarm was muted after detection, "
|
| 162 |
-
"warn='fault' as device fault, warn='remove' as device removed, warn='install' as device installed, "
|
| 163 |
-
"warn='low-vol' or warn='low-vol-test' as low battery/voltage, and warn='ok' as normal. "
|
| 164 |
-
"The answer must clearly state whether smoke/fire was detected and include the timestamps of those events."
|
| 165 |
-
)
|
| 166 |
-
|
| 167 |
-
type_guidance = ASSET_TYPE_ANALYSIS_GUIDANCE.get(asset.asset_type)
|
| 168 |
-
if type_guidance:
|
| 169 |
-
parts.append(type_guidance)
|
| 170 |
-
|
| 171 |
-
override = ASSET_NAME_OVERRIDES.get(asset.name)
|
| 172 |
-
if override:
|
| 173 |
-
parts.append(override)
|
| 174 |
-
|
| 175 |
-
if asset.analysis_instructions:
|
| 176 |
-
parts.append(f"Asset-specific rules: {asset.analysis_instructions}")
|
| 177 |
-
|
| 178 |
-
return "\n\n".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/services/asset_catalog.py
DELETED
|
@@ -1,65 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
|
| 5 |
-
import yaml
|
| 6 |
-
|
| 7 |
-
from agents.analysis.backend.app.models.schemas import AssetProfile
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class AssetCatalogService:
|
| 11 |
-
def __init__(self) -> None:
|
| 12 |
-
self._catalog_path = Path(__file__).resolve().parents[2] / "data" / "asset_catalog.yaml"
|
| 13 |
-
self._cache: dict[str, AssetProfile] | None = None
|
| 14 |
-
|
| 15 |
-
def _load(self) -> dict[str, AssetProfile]:
|
| 16 |
-
raw = yaml.safe_load(self._catalog_path.read_text(encoding="utf-8"))
|
| 17 |
-
assets = raw.get("assets", [])
|
| 18 |
-
return {
|
| 19 |
-
asset["asset_id"]: AssetProfile(**asset)
|
| 20 |
-
for asset in assets
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
def list_assets(self) -> list[AssetProfile]:
|
| 24 |
-
self._cache = self._load()
|
| 25 |
-
return list(self._cache.values())
|
| 26 |
-
|
| 27 |
-
def get_asset(self, asset_id: str) -> AssetProfile | None:
|
| 28 |
-
self._cache = self._load()
|
| 29 |
-
return self._cache.get(asset_id)
|
| 30 |
-
|
| 31 |
-
def get_asset_by_name(self, name: str) -> AssetProfile | None:
|
| 32 |
-
normalized_name = self._normalize(name)
|
| 33 |
-
for asset in self.list_assets():
|
| 34 |
-
aliases = [asset.name, asset.asset_id, *asset.lookup_names]
|
| 35 |
-
if any(self._normalize(alias) == normalized_name for alias in aliases):
|
| 36 |
-
return asset
|
| 37 |
-
return None
|
| 38 |
-
|
| 39 |
-
def find_asset_by_message(self, message: str) -> AssetProfile | None:
|
| 40 |
-
normalized_message = self._normalize(message)
|
| 41 |
-
best_match: AssetProfile | None = None
|
| 42 |
-
best_length = -1
|
| 43 |
-
|
| 44 |
-
for asset in self.list_assets():
|
| 45 |
-
aliases = [asset.asset_id, asset.name, *asset.lookup_names]
|
| 46 |
-
for alias in aliases:
|
| 47 |
-
normalized_alias = self._normalize(alias)
|
| 48 |
-
if normalized_alias and normalized_alias in normalized_message:
|
| 49 |
-
if len(normalized_alias) > best_length:
|
| 50 |
-
best_match = asset
|
| 51 |
-
best_length = len(normalized_alias)
|
| 52 |
-
return best_match
|
| 53 |
-
|
| 54 |
-
def list_asset_names(self) -> list[str]:
|
| 55 |
-
names: list[str] = []
|
| 56 |
-
for asset in self.list_assets():
|
| 57 |
-
names.append(asset.name)
|
| 58 |
-
names.extend(asset.lookup_names)
|
| 59 |
-
return sorted(set(names))
|
| 60 |
-
|
| 61 |
-
def _normalize(self, value: str) -> str:
|
| 62 |
-
return "".join(value.lower().split())
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
asset_catalog_service = AssetCatalogService()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/services/data_repository.py
DELETED
|
@@ -1,49 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from datetime import date, datetime
|
| 4 |
-
|
| 5 |
-
from sqlalchemy import create_engine, text
|
| 6 |
-
from sqlalchemy.exc import SQLAlchemyError
|
| 7 |
-
|
| 8 |
-
from agents.analysis.backend.app.core.config import get_settings
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class DataQueryError(RuntimeError):
|
| 12 |
-
pass
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class DataRepository:
|
| 16 |
-
def __init__(self) -> None:
|
| 17 |
-
self._settings = get_settings()
|
| 18 |
-
self._engine = create_engine(
|
| 19 |
-
self._normalize_database_url(self._settings.database_url),
|
| 20 |
-
future=True,
|
| 21 |
-
pool_pre_ping=True,
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
def execute_query(self, query: str, params: dict | None = None) -> list[dict]:
|
| 25 |
-
try:
|
| 26 |
-
with self._engine.connect() as connection:
|
| 27 |
-
result = connection.execute(text(query), params or {})
|
| 28 |
-
return [self._normalize_row(dict(row._mapping)) for row in result]
|
| 29 |
-
except SQLAlchemyError as exc:
|
| 30 |
-
raise DataQueryError(f"Database query failed: {exc.__class__.__name__}: {exc}") from exc
|
| 31 |
-
|
| 32 |
-
def _normalize_database_url(self, database_url: str) -> str:
|
| 33 |
-
if database_url.startswith("postgresql://"):
|
| 34 |
-
return database_url.replace("postgresql://", "postgresql+psycopg://", 1)
|
| 35 |
-
return database_url
|
| 36 |
-
|
| 37 |
-
def _normalize_row(self, row: dict) -> dict:
|
| 38 |
-
normalized: dict = {}
|
| 39 |
-
for key, value in row.items():
|
| 40 |
-
if isinstance(value, datetime):
|
| 41 |
-
normalized[key] = value.strftime("%d %b %Y, %I:%M:%S %p")
|
| 42 |
-
elif isinstance(value, date):
|
| 43 |
-
normalized[key] = value.strftime("%d %b %Y")
|
| 44 |
-
else:
|
| 45 |
-
normalized[key] = value
|
| 46 |
-
return normalized
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
data_repository = DataRepository()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/services/deterministic_analysis.py
DELETED
|
@@ -1,638 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from decimal import Decimal, InvalidOperation
|
| 4 |
-
from datetime import datetime, time
|
| 5 |
-
from statistics import mean
|
| 6 |
-
from collections import defaultdict
|
| 7 |
-
|
| 8 |
-
from agents.analysis.backend.app.models.schemas import AssetProfile
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class DeterministicAnalysisService:
|
| 12 |
-
def summarize(self, asset: AssetProfile, rows: list[dict]) -> dict[str, object]:
|
| 13 |
-
base = self._generic_summary(rows)
|
| 14 |
-
domain_summary = self._asset_specific_summary(asset, rows)
|
| 15 |
-
base["warnings"].extend(domain_summary["warnings"])
|
| 16 |
-
base["findings"] = domain_summary["findings"]
|
| 17 |
-
return base
|
| 18 |
-
|
| 19 |
-
def _generic_summary(self, rows: list[dict]) -> dict[str, object]:
|
| 20 |
-
if not rows:
|
| 21 |
-
return {
|
| 22 |
-
"numeric_metrics": {},
|
| 23 |
-
"warnings": ["No telemetry rows were returned for the requested window."],
|
| 24 |
-
"findings": [],
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
numeric_columns: dict[str, list[float]] = {}
|
| 28 |
-
for row in rows:
|
| 29 |
-
for key, value in row.items():
|
| 30 |
-
if isinstance(value, bool):
|
| 31 |
-
continue
|
| 32 |
-
numeric_value = self._as_float(value)
|
| 33 |
-
if numeric_value is not None:
|
| 34 |
-
numeric_columns.setdefault(key, []).append(numeric_value)
|
| 35 |
-
|
| 36 |
-
metric_summary: dict[str, dict[str, float]] = {}
|
| 37 |
-
warnings: list[str] = []
|
| 38 |
-
|
| 39 |
-
for column, values in numeric_columns.items():
|
| 40 |
-
if not values:
|
| 41 |
-
continue
|
| 42 |
-
avg = mean(values)
|
| 43 |
-
metric_summary[column] = {
|
| 44 |
-
"min": min(values),
|
| 45 |
-
"max": max(values),
|
| 46 |
-
"avg": round(avg, 3),
|
| 47 |
-
"latest": values[-1],
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
if len(values) > 1 and avg != 0:
|
| 51 |
-
spread = max(values) - min(values)
|
| 52 |
-
if spread > abs(avg) * 0.2:
|
| 53 |
-
warnings.append(
|
| 54 |
-
f"{column} changed noticeably across the requested window "
|
| 55 |
-
f"(min={min(values):.2f}, max={max(values):.2f})."
|
| 56 |
-
)
|
| 57 |
-
|
| 58 |
-
return {
|
| 59 |
-
"numeric_metrics": metric_summary,
|
| 60 |
-
"warnings": warnings,
|
| 61 |
-
"findings": self._generic_trend_findings(metric_summary),
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
def _asset_specific_summary(self, asset: AssetProfile, rows: list[dict]) -> dict[str, object]:
|
| 65 |
-
if asset.asset_type == "cold_room":
|
| 66 |
-
return self._cold_room_summary(asset, rows)
|
| 67 |
-
if asset.asset_type == "filling_machine":
|
| 68 |
-
return self._filling_machine_summary(rows)
|
| 69 |
-
if asset.asset_type == "tank":
|
| 70 |
-
return self._tank_summary(asset, rows)
|
| 71 |
-
if asset.asset_type == "smoke_alarm":
|
| 72 |
-
return self._smoke_alarm_summary(asset, rows)
|
| 73 |
-
return {"warnings": [], "findings": []}
|
| 74 |
-
|
| 75 |
-
def _smoke_alarm_summary(self, asset: AssetProfile, rows: list[dict]) -> dict[str, object]:
|
| 76 |
-
findings: list[str] = []
|
| 77 |
-
warnings: list[str] = []
|
| 78 |
-
|
| 79 |
-
per_asset: dict[str, list[dict]] = defaultdict(list)
|
| 80 |
-
for row in rows:
|
| 81 |
-
asset_name = str(row.get("asset_name") or asset.name or "Unknown")
|
| 82 |
-
per_asset[asset_name].append(row)
|
| 83 |
-
|
| 84 |
-
if not per_asset:
|
| 85 |
-
return {"warnings": ["No smoke-alarm telemetry rows were available for the requested window."], "findings": []}
|
| 86 |
-
|
| 87 |
-
smoke_events: list[tuple[str, datetime | None, str]] = []
|
| 88 |
-
muted_events: list[tuple[str, datetime | None, str]] = []
|
| 89 |
-
fault_events: list[tuple[str, datetime | None, str]] = []
|
| 90 |
-
remove_events: list[tuple[str, datetime | None, str]] = []
|
| 91 |
-
low_voltage_assets: set[str] = set()
|
| 92 |
-
unknown_assets: set[str] = set()
|
| 93 |
-
latest_states: list[str] = []
|
| 94 |
-
|
| 95 |
-
for asset_name, asset_rows in sorted(per_asset.items()):
|
| 96 |
-
ordered = sorted(asset_rows, key=lambda row: self._parse_datetime(row.get("recorded_at") or row.get("timestamp")) or datetime.min)
|
| 97 |
-
per_sensor: dict[str, list[dict]] = defaultdict(list)
|
| 98 |
-
for row in ordered:
|
| 99 |
-
sensor_name = str(row.get("attribute_name") or row.get("sensor_name") or "sensor")
|
| 100 |
-
per_sensor[sensor_name].append(row)
|
| 101 |
-
|
| 102 |
-
for row in ordered:
|
| 103 |
-
warn_type = self._normalize_warn_type(row.get("warn_type"))
|
| 104 |
-
event_time = self._parse_datetime(row.get("recorded_at") or row.get("timestamp"))
|
| 105 |
-
attribute_name = str(row.get("attribute_name") or row.get("sensor_name") or "sensor")
|
| 106 |
-
event_ref = (asset_name, event_time, attribute_name)
|
| 107 |
-
if warn_type == "warn":
|
| 108 |
-
smoke_events.append(event_ref)
|
| 109 |
-
elif warn_type == "mute":
|
| 110 |
-
muted_events.append(event_ref)
|
| 111 |
-
elif warn_type == "fault":
|
| 112 |
-
fault_events.append(event_ref)
|
| 113 |
-
elif warn_type == "remove":
|
| 114 |
-
remove_events.append(event_ref)
|
| 115 |
-
elif warn_type in {"low-vol", "low-vol-test"}:
|
| 116 |
-
low_voltage_assets.add(asset_name)
|
| 117 |
-
elif warn_type == "unknown":
|
| 118 |
-
unknown_assets.add(asset_name)
|
| 119 |
-
|
| 120 |
-
latest_sensor_summaries: list[str] = []
|
| 121 |
-
for sensor_name, sensor_rows in sorted(per_sensor.items()):
|
| 122 |
-
latest_row = sensor_rows[-1]
|
| 123 |
-
|
| 124 |
-
# Strict smoke detection: only if warn_type is 'warn'
|
| 125 |
-
is_actual_smoke = any(str(r.get("warn_type", "")).lower() == "warn" for r in sensor_rows)
|
| 126 |
-
latest_warn: str | None = "SMOKE DETECTED" if is_actual_smoke else self._normalize_warn_type(latest_row.get("warn_type"))
|
| 127 |
-
if is_actual_smoke:
|
| 128 |
-
smoke_events.append((asset_name, self._parse_datetime(latest_row.get("recorded_at") or latest_row.get("timestamp")), sensor_name))
|
| 129 |
-
|
| 130 |
-
latest_time = self._parse_datetime(latest_row.get("recorded_at") or latest_row.get("timestamp"))
|
| 131 |
-
latest_temp = self._as_float(latest_row.get("temperature"))
|
| 132 |
-
latest_humidity = self._as_float(latest_row.get("humidity"))
|
| 133 |
-
latest_battery = self._as_float(latest_row.get("bat_percent"))
|
| 134 |
-
|
| 135 |
-
raw_data = latest_row.get("sensor_data")
|
| 136 |
-
if raw_data is None and latest_warn is None:
|
| 137 |
-
# Explicit handling for assets with no data in the window
|
| 138 |
-
state_fragments = [f"{sensor_name}: No recent telemetry found (Offline)"]
|
| 139 |
-
latest_sensor_summaries.append(" ".join(state_fragments))
|
| 140 |
-
continue
|
| 141 |
-
|
| 142 |
-
state_fragments = [f"{sensor_name}: {latest_warn or 'ok'}"]
|
| 143 |
-
if latest_time is not None:
|
| 144 |
-
state_fragments.append(f"at {latest_time.strftime('%d %b %Y, %I:%M %p')}")
|
| 145 |
-
|
| 146 |
-
details: list[str] = []
|
| 147 |
-
if latest_temp is not None:
|
| 148 |
-
details.append(f"{latest_temp:.1f}C")
|
| 149 |
-
if latest_humidity is not None:
|
| 150 |
-
details.append(f"{latest_humidity:.1f}% RH")
|
| 151 |
-
if latest_battery is not None:
|
| 152 |
-
details.append(f"battery {latest_battery:.0f}%")
|
| 153 |
-
|
| 154 |
-
if isinstance(raw_data, dict):
|
| 155 |
-
skip_keys = {"warn", "temp", "humi", "bat_percent", "bat_voltage", "name", "timestamp"}
|
| 156 |
-
for k, v in raw_data.items():
|
| 157 |
-
if k.lower() in skip_keys:
|
| 158 |
-
continue
|
| 159 |
-
val = self._as_float(v)
|
| 160 |
-
if val is not None:
|
| 161 |
-
details.append(f"{k}: {val}")
|
| 162 |
-
elif isinstance(v, str) and len(v) < 20:
|
| 163 |
-
details.append(f"{k}: {v}")
|
| 164 |
-
else:
|
| 165 |
-
# Strict smoke detection: only if warn_type is 'warn'
|
| 166 |
-
warn_type = str(latest_row.get("warn_type", "")).lower()
|
| 167 |
-
is_actual_smoke = (warn_type == "warn")
|
| 168 |
-
|
| 169 |
-
if is_actual_smoke:
|
| 170 |
-
smoke_events.append(latest_row)
|
| 171 |
-
latest_warn = "SMOKE DETECTED"
|
| 172 |
-
# If it's a simple value, include it directly
|
| 173 |
-
val = self._as_float(raw_data)
|
| 174 |
-
if val is not None:
|
| 175 |
-
details.append(f"value: {val}")
|
| 176 |
-
elif raw_data is not None:
|
| 177 |
-
details.append(f"value: {raw_data}")
|
| 178 |
-
|
| 179 |
-
if details:
|
| 180 |
-
state_fragments.append(f"({', '.join(details)})")
|
| 181 |
-
|
| 182 |
-
# For 'ok' status, show sensor name and details but omit the word 'ok'.
|
| 183 |
-
if not latest_warn or latest_warn == 'ok':
|
| 184 |
-
# state_fragments[0] is like "data: ok" — keep the sensor name prefix, drop 'ok'
|
| 185 |
-
sensor_label = state_fragments[0].split(":")[0].strip() + ":"
|
| 186 |
-
ok_parts = [sensor_label] + state_fragments[1:] # sensor_name: <time> <details>
|
| 187 |
-
latest_sensor_summaries.append(" ".join(ok_parts))
|
| 188 |
-
else:
|
| 189 |
-
latest_sensor_summaries.append(" ".join(state_fragments))
|
| 190 |
-
|
| 191 |
-
if latest_sensor_summaries:
|
| 192 |
-
latest_states.append(f"{asset_name}: {'; '.join(latest_sensor_summaries)}")
|
| 193 |
-
|
| 194 |
-
if smoke_events:
|
| 195 |
-
preview = ", ".join(
|
| 196 |
-
f"{name} at {(event_time.strftime('%d %b %I:%M %p') if event_time else 'unknown time')}"
|
| 197 |
-
for name, event_time, _ in smoke_events[:6]
|
| 198 |
-
)
|
| 199 |
-
findings.append(f"Smoke/fire was detected in {len(smoke_events)} event(s): {preview}.")
|
| 200 |
-
else:
|
| 201 |
-
findings.append("No smoke/fire detection event was found in the returned telemetry window.")
|
| 202 |
-
|
| 203 |
-
if muted_events:
|
| 204 |
-
preview = ", ".join(
|
| 205 |
-
f"{name} at {(event_time.strftime('%d %b %I:%M %p') if event_time else 'unknown time')}"
|
| 206 |
-
for name, event_time, _ in muted_events[:6]
|
| 207 |
-
)
|
| 208 |
-
findings.append(f"Alarm mute events were recorded for {len(muted_events)} event(s): {preview}.")
|
| 209 |
-
|
| 210 |
-
if fault_events:
|
| 211 |
-
preview = ", ".join(
|
| 212 |
-
f"{name} at {(event_time.strftime('%d %b %I:%M %p') if event_time else 'unknown time')}"
|
| 213 |
-
for name, event_time, _ in fault_events[:6]
|
| 214 |
-
)
|
| 215 |
-
warnings.append(f"Fault events were detected on {len(fault_events)} row(s): {preview}.")
|
| 216 |
-
|
| 217 |
-
if remove_events:
|
| 218 |
-
preview = ", ".join(
|
| 219 |
-
f"{name} at {(event_time.strftime('%d %b %I:%M %p') if event_time else 'unknown time')}"
|
| 220 |
-
for name, event_time, _ in remove_events[:6]
|
| 221 |
-
)
|
| 222 |
-
warnings.append(f"Sensor removal events were detected: {preview}.")
|
| 223 |
-
|
| 224 |
-
if low_voltage_assets:
|
| 225 |
-
warnings.append(
|
| 226 |
-
f"Low-voltage/battery alerts appeared on {len(low_voltage_assets)} device(s): "
|
| 227 |
-
+ ", ".join(sorted(low_voltage_assets)[:8])
|
| 228 |
-
+ ("." if len(low_voltage_assets) <= 8 else " ...")
|
| 229 |
-
)
|
| 230 |
-
|
| 231 |
-
if unknown_assets:
|
| 232 |
-
warnings.append(
|
| 233 |
-
f"Unknown warn states were reported by {len(unknown_assets)} device(s): "
|
| 234 |
-
+ ", ".join(sorted(unknown_assets))
|
| 235 |
-
+ "."
|
| 236 |
-
)
|
| 237 |
-
|
| 238 |
-
findings.extend(latest_states)
|
| 239 |
-
return {"warnings": warnings, "findings": findings}
|
| 240 |
-
|
| 241 |
-
def _cold_room_summary(self, asset: AssetProfile, rows: list[dict]) -> dict[str, object]:
|
| 242 |
-
findings: list[str] = []
|
| 243 |
-
warnings: list[str] = []
|
| 244 |
-
def get_val(row, keys):
|
| 245 |
-
# Try direct columns first
|
| 246 |
-
for k in keys:
|
| 247 |
-
if (v := self._as_float(row.get(k))) is not None:
|
| 248 |
-
return v
|
| 249 |
-
# Then try nested in sensor_data
|
| 250 |
-
sd = row.get("sensor_data")
|
| 251 |
-
if isinstance(sd, dict):
|
| 252 |
-
for k in keys:
|
| 253 |
-
if (v := self._as_float(sd.get(k))) is not None:
|
| 254 |
-
return v
|
| 255 |
-
return None
|
| 256 |
-
|
| 257 |
-
temp_keys = asset.temperature_keys or ["Temperature", "temperature", "temp", "t", "temp1"]
|
| 258 |
-
humi_keys = asset.humidity_keys or ["Humidity", "humidity", "humi", "h", "hum"]
|
| 259 |
-
|
| 260 |
-
temps = [v for row in rows if (v := get_val(row, temp_keys)) is not None]
|
| 261 |
-
humis = [v for row in rows if (v := get_val(row, humi_keys)) is not None]
|
| 262 |
-
|
| 263 |
-
stale = any(bool(row.get("is_stale")) for row in rows)
|
| 264 |
-
min_ok = asset.nominal_range.get("min_temperature", 5.0)
|
| 265 |
-
max_ok = asset.nominal_range.get("max_temperature", 45.0)
|
| 266 |
-
|
| 267 |
-
if temps:
|
| 268 |
-
latest = temps[0]
|
| 269 |
-
earliest = temps[-1]
|
| 270 |
-
findings.append(f"Latest temperature is {latest:.2f}C.")
|
| 271 |
-
findings.append(f"Observed range is {min(temps):.2f}C to {max(temps):.2f}C.")
|
| 272 |
-
findings.append(
|
| 273 |
-
f"Temperature trend over the window is {self._describe_trend(latest - earliest, 'C')}."
|
| 274 |
-
)
|
| 275 |
-
|
| 276 |
-
# Use 20.0 as hardcoded cycle threshold if not specified,
|
| 277 |
-
# but don't use it for nominal range warnings.
|
| 278 |
-
cycle_threshold = max_ok if max_ok is not None else 20.0
|
| 279 |
-
cycles = self._estimate_cooling_cycles(temps, threshold=cycle_threshold)
|
| 280 |
-
findings.append(f"Estimated cooling cycle count in this window: {cycles}.")
|
| 281 |
-
|
| 282 |
-
consumption_summary = self._cooling_consumption_summary(rows)
|
| 283 |
-
if consumption_summary:
|
| 284 |
-
findings.extend(consumption_summary["findings"])
|
| 285 |
-
warnings.extend(consumption_summary["warnings"])
|
| 286 |
-
|
| 287 |
-
if min_ok is not None and max_ok is not None:
|
| 288 |
-
if latest < min_ok or latest > max_ok:
|
| 289 |
-
warnings.append(
|
| 290 |
-
f"Latest temperature {latest:.2f}C is outside the nominal range of {min_ok:.2f}C to {max_ok:.2f}C."
|
| 291 |
-
)
|
| 292 |
-
if max(temps) > max_ok:
|
| 293 |
-
warnings.append(f"Temperature crossed the upper threshold of {max_ok:.2f}C.")
|
| 294 |
-
|
| 295 |
-
if stale:
|
| 296 |
-
warnings.append("The most recent cold room payload appears stale.")
|
| 297 |
-
|
| 298 |
-
return {"warnings": warnings, "findings": findings}
|
| 299 |
-
|
| 300 |
-
def _filling_machine_summary(self, rows: list[dict]) -> dict[str, object]:
|
| 301 |
-
findings: list[str] = []
|
| 302 |
-
warnings: list[str] = []
|
| 303 |
-
totals = [
|
| 304 |
-
numeric
|
| 305 |
-
for row in rows
|
| 306 |
-
if (numeric := self._as_float(row.get("total_count"))) is not None
|
| 307 |
-
]
|
| 308 |
-
stale = any(bool(row.get("is_stale")) for row in rows)
|
| 309 |
-
|
| 310 |
-
if totals:
|
| 311 |
-
latest = totals[0]
|
| 312 |
-
earliest = totals[-1]
|
| 313 |
-
findings.append(f"Latest total production count is {latest:.0f}.")
|
| 314 |
-
findings.append(f"Net production movement in this window is {latest - earliest:.0f}.")
|
| 315 |
-
findings.append(
|
| 316 |
-
f"Production trend over the window is {self._describe_trend(latest - earliest, 'units', precision=0)}."
|
| 317 |
-
)
|
| 318 |
-
if latest == earliest and len(totals) > 1:
|
| 319 |
-
warnings.append("Production total did not change during the requested window.")
|
| 320 |
-
|
| 321 |
-
shift_columns = ["shift_1_count", "shift_2_count", "shift_3_count"]
|
| 322 |
-
shift_values = {
|
| 323 |
-
column: numeric
|
| 324 |
-
for column in shift_columns
|
| 325 |
-
if rows and (numeric := self._as_float(rows[0].get(column))) is not None
|
| 326 |
-
}
|
| 327 |
-
if shift_values:
|
| 328 |
-
best_shift = max(shift_values, key=shift_values.get)
|
| 329 |
-
findings.append(f"The strongest current shift metric is {best_shift} at {shift_values[best_shift]:.0f}.")
|
| 330 |
-
|
| 331 |
-
if stale:
|
| 332 |
-
warnings.append("The filling machine payload timestamp appears stale.")
|
| 333 |
-
|
| 334 |
-
return {"warnings": warnings, "findings": findings}
|
| 335 |
-
|
| 336 |
-
def _tank_summary(self, asset: AssetProfile, rows: list[dict]) -> dict[str, object]:
|
| 337 |
-
findings: list[str] = []
|
| 338 |
-
warnings: list[str] = []
|
| 339 |
-
low_level_threshold = asset.nominal_range.get("low_level_threshold", 3.0)
|
| 340 |
-
|
| 341 |
-
per_asset_points: dict[str, list[tuple[datetime, float, float | None, float | None]]] = defaultdict(list)
|
| 342 |
-
for row in rows:
|
| 343 |
-
level = self._as_float(row.get("current_level_ft"))
|
| 344 |
-
if level is None:
|
| 345 |
-
continue
|
| 346 |
-
raw_asset_name = row.get("asset_name") or row.get("name") or "Unknown"
|
| 347 |
-
asset_name = str(raw_asset_name)
|
| 348 |
-
timestamp = (
|
| 349 |
-
self._parse_datetime(row.get("recorded_at"))
|
| 350 |
-
or self._parse_datetime(row.get("timestamp"))
|
| 351 |
-
or self._parse_datetime(row.get("latest_time"))
|
| 352 |
-
or datetime.min
|
| 353 |
-
)
|
| 354 |
-
pct = self._as_float(row.get("percentage_filled"))
|
| 355 |
-
max_ft = self._as_float(row.get("max_ft"))
|
| 356 |
-
per_asset_points[asset_name].append((timestamp, level, pct, max_ft))
|
| 357 |
-
|
| 358 |
-
if not per_asset_points:
|
| 359 |
-
return {"warnings": ["No valid tank level points were available for deterministic analysis."], "findings": []}
|
| 360 |
-
|
| 361 |
-
per_asset_stats: list[dict[str, float | str | None]] = []
|
| 362 |
-
missing_capacity_assets: set[str] = set()
|
| 363 |
-
for asset_name, points in per_asset_points.items():
|
| 364 |
-
ordered = sorted(points, key=lambda item: item[0])
|
| 365 |
-
start_level = ordered[0][1]
|
| 366 |
-
end_level = ordered[-1][1]
|
| 367 |
-
min_level = min(point[1] for point in ordered)
|
| 368 |
-
max_level = max(point[1] for point in ordered)
|
| 369 |
-
pct_candidates = [point[2] for point in ordered if point[2] is not None]
|
| 370 |
-
max_ft_candidates = [point[3] for point in ordered if point[3] is not None]
|
| 371 |
-
if not max_ft_candidates:
|
| 372 |
-
missing_capacity_assets.add(asset_name)
|
| 373 |
-
end_pct = pct_candidates[-1] if pct_candidates else None
|
| 374 |
-
per_asset_stats.append(
|
| 375 |
-
{
|
| 376 |
-
"asset_name": asset_name,
|
| 377 |
-
"start_level": start_level,
|
| 378 |
-
"end_level": end_level,
|
| 379 |
-
"min_level": min_level,
|
| 380 |
-
"max_level": max_level,
|
| 381 |
-
"net_change": end_level - start_level,
|
| 382 |
-
"end_pct": end_pct,
|
| 383 |
-
}
|
| 384 |
-
)
|
| 385 |
-
|
| 386 |
-
per_asset_stats.sort(key=lambda item: str(item["asset_name"]))
|
| 387 |
-
tank_count = len(per_asset_stats)
|
| 388 |
-
low_assets = [item for item in per_asset_stats if float(item["end_level"]) <= low_level_threshold]
|
| 389 |
-
depleted_assets = [item for item in per_asset_stats if float(item["net_change"]) < 0]
|
| 390 |
-
refilled_assets = [item for item in per_asset_stats if float(item["net_change"]) > 0]
|
| 391 |
-
|
| 392 |
-
findings.append(f"Analyzed {tank_count} tank assets across the requested window.")
|
| 393 |
-
findings.append(
|
| 394 |
-
f"End-of-window levels range from {min(float(item['end_level']) for item in per_asset_stats):.2f} ft to "
|
| 395 |
-
f"{max(float(item['end_level']) for item in per_asset_stats):.2f} ft."
|
| 396 |
-
)
|
| 397 |
-
|
| 398 |
-
if low_assets:
|
| 399 |
-
preview = ", ".join(
|
| 400 |
-
f"{str(item['asset_name'])} ({float(item['end_level']):.2f} ft)"
|
| 401 |
-
for item in low_assets[:5]
|
| 402 |
-
)
|
| 403 |
-
extra = f" +{len(low_assets) - 5} more" if len(low_assets) > 5 else ""
|
| 404 |
-
findings.append(
|
| 405 |
-
f"{len(low_assets)} tank(s) ended at or below {low_level_threshold:.1f} ft: {preview}{extra}."
|
| 406 |
-
)
|
| 407 |
-
else:
|
| 408 |
-
findings.append(f"No tank ended below the {low_level_threshold:.1f} ft threshold.")
|
| 409 |
-
|
| 410 |
-
findings.append(
|
| 411 |
-
f"Net movement summary: {len(depleted_assets)} tank(s) depleted, {len(refilled_assets)} tank(s) increased."
|
| 412 |
-
)
|
| 413 |
-
|
| 414 |
-
# Keep warnings concise and actionable.
|
| 415 |
-
if low_assets:
|
| 416 |
-
warnings.append(
|
| 417 |
-
f"Low-level risk in {len(low_assets)} tank(s); prioritize refill planning for those assets."
|
| 418 |
-
)
|
| 419 |
-
if missing_capacity_assets:
|
| 420 |
-
warnings.append(
|
| 421 |
-
f"MaxFt missing for {len(missing_capacity_assets)} asset(s), percentage-filled may be incomplete."
|
| 422 |
-
)
|
| 423 |
-
|
| 424 |
-
# Include a short sample of per-asset outcomes for LLM grounding.
|
| 425 |
-
compact_asset_lines = []
|
| 426 |
-
for item in per_asset_stats[:10]:
|
| 427 |
-
pct_text = (
|
| 428 |
-
f", {float(item['end_pct']):.1f}%"
|
| 429 |
-
if item["end_pct"] is not None
|
| 430 |
-
else ""
|
| 431 |
-
)
|
| 432 |
-
compact_asset_lines.append(
|
| 433 |
-
f"{item['asset_name']}: start {float(item['start_level']):.2f} ft, "
|
| 434 |
-
f"end {float(item['end_level']):.2f} ft, "
|
| 435 |
-
f"min {float(item['min_level']):.2f} ft, max {float(item['max_level']):.2f} ft{pct_text}."
|
| 436 |
-
)
|
| 437 |
-
findings.extend(compact_asset_lines)
|
| 438 |
-
|
| 439 |
-
# Minimal window-level warning summary.
|
| 440 |
-
stale = any(bool(row.get("is_stale")) for row in rows)
|
| 441 |
-
if stale:
|
| 442 |
-
warnings.append("Some tank readings in the window were stale.")
|
| 443 |
-
|
| 444 |
-
large_drops = [
|
| 445 |
-
item for item in per_asset_stats
|
| 446 |
-
if float(item["net_change"]) <= -1.5
|
| 447 |
-
]
|
| 448 |
-
if large_drops:
|
| 449 |
-
warnings.append(f"Sharp depletion detected in {len(large_drops)} tank(s).")
|
| 450 |
-
|
| 451 |
-
return {"warnings": warnings, "findings": findings}
|
| 452 |
-
|
| 453 |
-
def _as_float(self, value: object) -> float | None:
|
| 454 |
-
if isinstance(value, bool) or value is None:
|
| 455 |
-
return None
|
| 456 |
-
if isinstance(value, (int, float, Decimal)):
|
| 457 |
-
return float(value)
|
| 458 |
-
if isinstance(value, str):
|
| 459 |
-
stripped = value.strip()
|
| 460 |
-
if not stripped:
|
| 461 |
-
return None
|
| 462 |
-
try:
|
| 463 |
-
return float(Decimal(stripped))
|
| 464 |
-
except (InvalidOperation, ValueError):
|
| 465 |
-
return None
|
| 466 |
-
return None
|
| 467 |
-
|
| 468 |
-
def _estimate_cooling_cycles(self, temperatures: list[float], threshold: float) -> int:
|
| 469 |
-
cycles = 0
|
| 470 |
-
cooling = False
|
| 471 |
-
for temperature in reversed(temperatures):
|
| 472 |
-
if not cooling and temperature > threshold:
|
| 473 |
-
cooling = True
|
| 474 |
-
elif cooling and temperature <= threshold:
|
| 475 |
-
cycles += 1
|
| 476 |
-
cooling = False
|
| 477 |
-
return cycles
|
| 478 |
-
|
| 479 |
-
def _cooling_consumption_summary(self, rows: list[dict]) -> dict[str, list[str]]:
|
| 480 |
-
points: list[tuple[datetime, float]] = []
|
| 481 |
-
for row in rows:
|
| 482 |
-
timestamp = self._parse_datetime(row.get("recorded_at")) or self._parse_datetime(row.get("timestamp"))
|
| 483 |
-
temperature = self._as_float(row.get("temperature"))
|
| 484 |
-
if timestamp is None or temperature is None:
|
| 485 |
-
continue
|
| 486 |
-
points.append((timestamp, temperature))
|
| 487 |
-
|
| 488 |
-
if len(points) < 3:
|
| 489 |
-
return {"findings": [], "warnings": []}
|
| 490 |
-
|
| 491 |
-
points.sort(key=lambda item: item[0])
|
| 492 |
-
threshold = 20.0
|
| 493 |
-
last_peak_idx = 0
|
| 494 |
-
in_cycle = False
|
| 495 |
-
cycle_start_idx: int | None = None
|
| 496 |
-
valley_idx: int | None = None
|
| 497 |
-
cycles: list[tuple[datetime, datetime]] = []
|
| 498 |
-
|
| 499 |
-
for idx in range(1, len(points)):
|
| 500 |
-
prev_temp = points[idx - 1][1]
|
| 501 |
-
current_temp = points[idx][1]
|
| 502 |
-
|
| 503 |
-
if not in_cycle:
|
| 504 |
-
if current_temp >= prev_temp:
|
| 505 |
-
last_peak_idx = idx
|
| 506 |
-
elif current_temp < prev_temp and current_temp < threshold:
|
| 507 |
-
cycle_start_idx = last_peak_idx
|
| 508 |
-
valley_idx = idx
|
| 509 |
-
in_cycle = True
|
| 510 |
-
else:
|
| 511 |
-
if valley_idx is None or current_temp <= points[valley_idx][1]:
|
| 512 |
-
valley_idx = idx
|
| 513 |
-
if current_temp >= threshold:
|
| 514 |
-
start_time = points[cycle_start_idx or 0][0]
|
| 515 |
-
end_time = points[valley_idx or idx][0]
|
| 516 |
-
cycles.append((start_time, end_time))
|
| 517 |
-
in_cycle = False
|
| 518 |
-
cycle_start_idx = None
|
| 519 |
-
valley_idx = None
|
| 520 |
-
last_peak_idx = idx
|
| 521 |
-
|
| 522 |
-
if in_cycle and cycle_start_idx is not None:
|
| 523 |
-
cycles.append((points[cycle_start_idx][0], points[-1][0]))
|
| 524 |
-
|
| 525 |
-
if not cycles:
|
| 526 |
-
return {
|
| 527 |
-
"findings": ["No confirmed cooling cycle reached the ON threshold of 20C in the returned time window."],
|
| 528 |
-
"warnings": [],
|
| 529 |
-
}
|
| 530 |
-
|
| 531 |
-
shift_anchor = datetime.combine(points[0][0].date(), time(hour=6), tzinfo=points[0][0].tzinfo)
|
| 532 |
-
total_on_seconds = 0.0
|
| 533 |
-
clipped_cycles: list[tuple[datetime, datetime]] = []
|
| 534 |
-
for start_time, end_time in cycles:
|
| 535 |
-
adjusted_start = max(start_time, shift_anchor)
|
| 536 |
-
if end_time > adjusted_start:
|
| 537 |
-
total_on_seconds += (end_time - adjusted_start).total_seconds()
|
| 538 |
-
clipped_cycles.append((adjusted_start, end_time))
|
| 539 |
-
|
| 540 |
-
total_hours = total_on_seconds / 3600
|
| 541 |
-
findings = [
|
| 542 |
-
f"Inferred cooling cycle count for the requested window is {len(clipped_cycles)}.",
|
| 543 |
-
f"Total inferred ON time from temperature-driven cooling cycles is {total_hours:.2f} hours, clipped to the 6:00 AM shift start where needed.",
|
| 544 |
-
]
|
| 545 |
-
if clipped_cycles and clipped_cycles[-1][1] == points[-1][0]:
|
| 546 |
-
findings.append("The latest cooling cycle appears to still be active, so ON time was counted up to the newest timestamp.")
|
| 547 |
-
|
| 548 |
-
return {"findings": findings, "warnings": []}
|
| 549 |
-
|
| 550 |
-
def _generic_trend_findings(self, metric_summary: dict[str, dict[str, float]]) -> list[str]:
|
| 551 |
-
findings: list[str] = []
|
| 552 |
-
priority_columns = [
|
| 553 |
-
"temperature",
|
| 554 |
-
"humidity",
|
| 555 |
-
"total_count",
|
| 556 |
-
"current_level_ft",
|
| 557 |
-
"level_change",
|
| 558 |
-
]
|
| 559 |
-
emitted = 0
|
| 560 |
-
for column in priority_columns + [column for column in metric_summary if column not in priority_columns]:
|
| 561 |
-
summary = metric_summary.get(column)
|
| 562 |
-
if not summary:
|
| 563 |
-
continue
|
| 564 |
-
drift = summary["latest"] - summary["avg"]
|
| 565 |
-
findings.append(
|
| 566 |
-
f"{column} latest value is {summary['latest']:.2f}, with a window average of {summary['avg']:.2f} and range {summary['min']:.2f} to {summary['max']:.2f}."
|
| 567 |
-
)
|
| 568 |
-
if abs(drift) > 0:
|
| 569 |
-
findings.append(
|
| 570 |
-
f"{column} is currently {self._describe_trend(drift, '', include_magnitude=False)} versus its window average."
|
| 571 |
-
)
|
| 572 |
-
emitted += 1
|
| 573 |
-
if emitted >= 2:
|
| 574 |
-
break
|
| 575 |
-
return findings
|
| 576 |
-
|
| 577 |
-
def _describe_trend(
|
| 578 |
-
self,
|
| 579 |
-
delta: float,
|
| 580 |
-
unit: str,
|
| 581 |
-
*,
|
| 582 |
-
precision: int = 2,
|
| 583 |
-
include_magnitude: bool = True,
|
| 584 |
-
) -> str:
|
| 585 |
-
magnitude = f"{abs(delta):.{precision}f}"
|
| 586 |
-
unit_suffix = f" {unit}".rstrip() if unit else ""
|
| 587 |
-
if abs(delta) < 1e-9:
|
| 588 |
-
return "flat"
|
| 589 |
-
if delta > 0:
|
| 590 |
-
return f"up by {magnitude}{unit_suffix}" if include_magnitude else "above"
|
| 591 |
-
return f"down by {magnitude}{unit_suffix}" if include_magnitude else "below"
|
| 592 |
-
|
| 593 |
-
def _parse_datetime(self, value: object) -> datetime | None:
|
| 594 |
-
if isinstance(value, datetime):
|
| 595 |
-
return value
|
| 596 |
-
if not isinstance(value, str):
|
| 597 |
-
return None
|
| 598 |
-
for fmt in ("%d %b %Y, %I:%M:%S %p", "%d %b %Y, %I:%M %p", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
|
| 599 |
-
try:
|
| 600 |
-
return datetime.strptime(value, fmt)
|
| 601 |
-
except ValueError:
|
| 602 |
-
continue
|
| 603 |
-
return None
|
| 604 |
-
|
| 605 |
-
def _normalize_warn_type(self, value: object) -> str:
|
| 606 |
-
text = str(value or "").strip().lower()
|
| 607 |
-
if not text:
|
| 608 |
-
return ""
|
| 609 |
-
mapping = {
|
| 610 |
-
"0x11": "warn",
|
| 611 |
-
"11": "warn",
|
| 612 |
-
"0x12": "mute",
|
| 613 |
-
"12": "mute",
|
| 614 |
-
"0x14": "low-vol",
|
| 615 |
-
"14": "low-vol",
|
| 616 |
-
"0x15": "fault",
|
| 617 |
-
"15": "fault",
|
| 618 |
-
"0x17": "ok",
|
| 619 |
-
"17": "ok",
|
| 620 |
-
"0x1a": "remove",
|
| 621 |
-
"1a": "remove",
|
| 622 |
-
"26": "remove",
|
| 623 |
-
"0x1b": "install",
|
| 624 |
-
"1b": "install",
|
| 625 |
-
"27": "install",
|
| 626 |
-
"0x1e": "ok-vol-test",
|
| 627 |
-
"1e": "ok-vol-test",
|
| 628 |
-
"30": "ok-vol-test",
|
| 629 |
-
"0x1f": "low-vol-test",
|
| 630 |
-
"1f": "low-vol-test",
|
| 631 |
-
"31": "low-vol-test",
|
| 632 |
-
"1": "warn",
|
| 633 |
-
"true": "warn",
|
| 634 |
-
}
|
| 635 |
-
return mapping.get(text, text)
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
deterministic_analysis_service = DeterministicAnalysisService()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/services/live_asset_registry.py
DELETED
|
@@ -1,469 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import time
|
| 4 |
-
from collections import Counter
|
| 5 |
-
|
| 6 |
-
from agents.analysis.backend.app.core.config import get_settings
|
| 7 |
-
from agents.analysis.backend.app.models.schemas import AssetProfile
|
| 8 |
-
from agents.analysis.backend.app.services.asset_catalog import asset_catalog_service
|
| 9 |
-
from agents.analysis.backend.app.services.data_repository import DataQueryError, data_repository
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
class LiveAssetRegistry:
|
| 13 |
-
def __init__(self) -> None:
|
| 14 |
-
self._settings = get_settings()
|
| 15 |
-
self._cache: dict[str, list[AssetProfile]] = {} # user_id -> assets
|
| 16 |
-
self._loaded_at: dict[str, float] = {}
|
| 17 |
-
self._ttl_seconds = 60.0
|
| 18 |
-
|
| 19 |
-
def list_assets(self, user_id: str = "user_1", force_refresh: bool = False) -> list[AssetProfile]:
|
| 20 |
-
now = time.time()
|
| 21 |
-
user_cache = self._cache.get(user_id, [])
|
| 22 |
-
user_loaded_at = self._loaded_at.get(user_id, 0.0)
|
| 23 |
-
|
| 24 |
-
if force_refresh or not user_cache or now - user_loaded_at > self._ttl_seconds:
|
| 25 |
-
self._cache[user_id] = self._load_assets(user_id)
|
| 26 |
-
self._loaded_at[user_id] = now
|
| 27 |
-
|
| 28 |
-
return self._cache[user_id]
|
| 29 |
-
|
| 30 |
-
def get_asset(self, asset_id: str, user_id: str = "user_1") -> AssetProfile | None:
|
| 31 |
-
for asset in self.list_assets(user_id):
|
| 32 |
-
aliases = [asset.asset_id, asset.db_asset_id, asset.name, *asset.lookup_names]
|
| 33 |
-
if asset_id in aliases:
|
| 34 |
-
return asset
|
| 35 |
-
return None
|
| 36 |
-
|
| 37 |
-
def find_candidates(self, message: str, user_id: str = "user_1", limit: int = 12) -> list[AssetProfile]:
|
| 38 |
-
normalized_message = self._normalize(message)
|
| 39 |
-
message_tokens = self._tokenize(message)
|
| 40 |
-
scored: list[tuple[int, int, int, int, int, str, AssetProfile]] = []
|
| 41 |
-
|
| 42 |
-
normalized_message_aggressive = "".join(char for char in normalized_message if char.isalnum())
|
| 43 |
-
singular_message_aggressive = normalized_message_aggressive.rstrip('s')
|
| 44 |
-
|
| 45 |
-
for asset in self.list_assets(user_id):
|
| 46 |
-
score = 0
|
| 47 |
-
aliases = [asset.name, asset.asset_id, *asset.lookup_names]
|
| 48 |
-
for alias in aliases:
|
| 49 |
-
normalized_alias = self._normalize(alias)
|
| 50 |
-
if not normalized_alias:
|
| 51 |
-
continue
|
| 52 |
-
|
| 53 |
-
normalized_alias_aggressive = "".join(char for char in normalized_alias if char.isalnum())
|
| 54 |
-
singular_alias_aggressive = normalized_alias_aggressive.rstrip('s')
|
| 55 |
-
|
| 56 |
-
# 1. Exact or substring match on aggressive normalization
|
| 57 |
-
if normalized_alias_aggressive == normalized_message_aggressive:
|
| 58 |
-
score = max(score, 1000)
|
| 59 |
-
elif singular_alias_aggressive in singular_message_aggressive or singular_message_aggressive in singular_alias_aggressive:
|
| 60 |
-
score = max(score, 850 + len(singular_alias_aggressive))
|
| 61 |
-
else:
|
| 62 |
-
# 2. Token-level fuzzy match (partial word match)
|
| 63 |
-
alias_tokens = self._tokenize(alias)
|
| 64 |
-
singular_message_tokens = [t.rstrip('s') for t in message_tokens]
|
| 65 |
-
|
| 66 |
-
# Check if any alias token is a substring of any message token (or vice versa)
|
| 67 |
-
overlap_count = 0
|
| 68 |
-
for t_alias in alias_tokens:
|
| 69 |
-
t_alias_singular = t_alias.rstrip('s')
|
| 70 |
-
if any(t_alias_singular in t_msg or t_msg in t_alias_singular for t_msg in singular_message_tokens):
|
| 71 |
-
overlap_count += 1
|
| 72 |
-
|
| 73 |
-
if overlap_count:
|
| 74 |
-
score = max(score, 200 + overlap_count * 150)
|
| 75 |
-
|
| 76 |
-
# Bonus for asset type match
|
| 77 |
-
if asset.asset_type and asset.asset_type.replace('_', '').rstrip('s') in normalized_message_aggressive:
|
| 78 |
-
score += 300
|
| 79 |
-
|
| 80 |
-
if asset.parent_name:
|
| 81 |
-
parent_tokens = self._tokenize(asset.parent_name)
|
| 82 |
-
parent_overlap = any(pt.rstrip('s') in normalized_message_aggressive for pt in parent_tokens)
|
| 83 |
-
if parent_overlap:
|
| 84 |
-
score += 100
|
| 85 |
-
|
| 86 |
-
if score:
|
| 87 |
-
scored.append((
|
| 88 |
-
score,
|
| 89 |
-
1 if asset.has_live_data else 0,
|
| 90 |
-
asset.child_count,
|
| 91 |
-
1 if asset.is_device else 0,
|
| 92 |
-
len(asset.name),
|
| 93 |
-
asset.db_asset_id or asset.asset_id,
|
| 94 |
-
asset,
|
| 95 |
-
))
|
| 96 |
-
|
| 97 |
-
scored.sort(key=lambda item: item[:-1], reverse=True)
|
| 98 |
-
return [asset for *_, asset in scored[:limit]]
|
| 99 |
-
|
| 100 |
-
def _is_general_status_query(self, query: str) -> bool:
|
| 101 |
-
normalized = "".join(query.lower().split())
|
| 102 |
-
for kw in ["systemstatus", "statusofall", "allassets", "alldevices", "completestatus", "statusreport"]:
|
| 103 |
-
if kw in normalized:
|
| 104 |
-
return True
|
| 105 |
-
return False
|
| 106 |
-
|
| 107 |
-
def find_mentioned_assets(self, message: str, user_id: str = "user_1") -> list[AssetProfile]:
|
| 108 |
-
normalized_message = self._normalize(message)
|
| 109 |
-
mentioned: list[AssetProfile] = []
|
| 110 |
-
|
| 111 |
-
# Sort assets by name length descending to avoid substring overlapping (e.g. ColdRoom1 vs ColdRoom10)
|
| 112 |
-
sorted_assets = sorted(self.list_assets(user_id), key=lambda a: len(a.name), reverse=True)
|
| 113 |
-
|
| 114 |
-
is_general_status = self._is_general_status_query(message)
|
| 115 |
-
temp_message = normalized_message
|
| 116 |
-
for asset in sorted_assets:
|
| 117 |
-
if is_general_status and asset.name.lower() == 'status':
|
| 118 |
-
continue
|
| 119 |
-
aliases = [asset.name, *asset.lookup_names]
|
| 120 |
-
matched = False
|
| 121 |
-
for alias in aliases:
|
| 122 |
-
norm_alias = self._normalize(alias)
|
| 123 |
-
if not norm_alias or len(norm_alias) < 3:
|
| 124 |
-
continue
|
| 125 |
-
if norm_alias in temp_message:
|
| 126 |
-
matched = True
|
| 127 |
-
temp_message = temp_message.replace(norm_alias, " ")
|
| 128 |
-
break
|
| 129 |
-
if matched:
|
| 130 |
-
mentioned.append(asset)
|
| 131 |
-
|
| 132 |
-
# If both a physical/non-Status asset and 'Status' are matched, discard 'Status'
|
| 133 |
-
if len(mentioned) >= 2:
|
| 134 |
-
has_physical = any(a.name.lower() != 'status' for a in mentioned)
|
| 135 |
-
if has_physical:
|
| 136 |
-
mentioned = [a for a in mentioned if a.name.lower() != 'status']
|
| 137 |
-
|
| 138 |
-
return mentioned
|
| 139 |
-
|
| 140 |
-
def best_direct_match(self, message: str, user_id: str = "user_1") -> AssetProfile | None:
|
| 141 |
-
# Check if multiple assets are mentioned and share a common parent
|
| 142 |
-
mentioned = self.find_mentioned_assets(message, user_id)
|
| 143 |
-
if len(mentioned) == 1:
|
| 144 |
-
return mentioned[0]
|
| 145 |
-
|
| 146 |
-
if len(mentioned) >= 2:
|
| 147 |
-
parent_names = {a.parent_name for a in mentioned if a.parent_name}
|
| 148 |
-
if len(parent_names) == 1:
|
| 149 |
-
common_parent_name = list(parent_names)[0]
|
| 150 |
-
parent_asset = self.get_asset(common_parent_name, user_id)
|
| 151 |
-
if parent_asset:
|
| 152 |
-
return parent_asset
|
| 153 |
-
|
| 154 |
-
normalized_message = self._normalize(message)
|
| 155 |
-
best: tuple[int, int, int, int, int, str, AssetProfile] | None = None
|
| 156 |
-
|
| 157 |
-
is_general_status = self._is_general_status_query(message)
|
| 158 |
-
|
| 159 |
-
for asset in self.list_assets(user_id):
|
| 160 |
-
if is_general_status and asset.name.lower() == 'status':
|
| 161 |
-
continue
|
| 162 |
-
aliases = [asset.name, *asset.lookup_names]
|
| 163 |
-
best_alias_score = 0
|
| 164 |
-
for alias in aliases:
|
| 165 |
-
normalized_alias = self._normalize(alias)
|
| 166 |
-
if not normalized_alias:
|
| 167 |
-
continue
|
| 168 |
-
if normalized_alias == normalized_message:
|
| 169 |
-
best_alias_score = max(best_alias_score, 5000 + len(normalized_alias) * 5)
|
| 170 |
-
elif normalized_alias in normalized_message:
|
| 171 |
-
best_alias_score = max(best_alias_score, 3000 + len(normalized_alias) * 5)
|
| 172 |
-
|
| 173 |
-
if best_alias_score:
|
| 174 |
-
candidate = (
|
| 175 |
-
best_alias_score,
|
| 176 |
-
1 if asset.has_live_data else 0,
|
| 177 |
-
asset.child_count,
|
| 178 |
-
1 if asset.is_device else 0,
|
| 179 |
-
len(asset.name),
|
| 180 |
-
asset.db_asset_id or asset.asset_id,
|
| 181 |
-
asset,
|
| 182 |
-
)
|
| 183 |
-
if best is None or candidate[:-1] > best[:-1]:
|
| 184 |
-
best = candidate
|
| 185 |
-
|
| 186 |
-
return best[-1] if best else None
|
| 187 |
-
|
| 188 |
-
def _load_assets(self, user_id: str) -> list[AssetProfile]:
|
| 189 |
-
query = f"""
|
| 190 |
-
WITH RECURSIVE hierarchy AS (
|
| 191 |
-
SELECT a.id, a.name, a.parent_id, a.attributes::jsonb AS attributes
|
| 192 |
-
FROM {self._settings.openremote_schema}.asset a
|
| 193 |
-
JOIN {self._settings.openremote_schema}.user_asset_link ual ON a.id = ual.asset_id
|
| 194 |
-
WHERE ual.user_id = :user_id
|
| 195 |
-
|
| 196 |
-
UNION
|
| 197 |
-
|
| 198 |
-
SELECT child.id, child.name, child.parent_id, child.attributes::jsonb AS attributes
|
| 199 |
-
FROM {self._settings.openremote_schema}.asset child
|
| 200 |
-
JOIN hierarchy h ON child.parent_id = h.id
|
| 201 |
-
),
|
| 202 |
-
latest_data AS (
|
| 203 |
-
SELECT DISTINCT ON (ad.entity_id, ad.attribute_name)
|
| 204 |
-
ad.entity_id,
|
| 205 |
-
ad.attribute_name,
|
| 206 |
-
ad.value,
|
| 207 |
-
ad.timestamp
|
| 208 |
-
FROM {self._settings.openremote_schema}.asset_datapoint ad
|
| 209 |
-
JOIN hierarchy h ON ad.entity_id = h.id
|
| 210 |
-
WHERE jsonb_typeof(ad.value::jsonb) = 'object'
|
| 211 |
-
ORDER BY ad.entity_id, ad.attribute_name, ad.timestamp DESC
|
| 212 |
-
),
|
| 213 |
-
latest_entities AS (
|
| 214 |
-
SELECT DISTINCT entity_id
|
| 215 |
-
FROM latest_data
|
| 216 |
-
),
|
| 217 |
-
child_counts AS (
|
| 218 |
-
SELECT parent_id, COUNT(*) AS child_count
|
| 219 |
-
FROM {self._settings.openremote_schema}.asset
|
| 220 |
-
WHERE parent_id IN (SELECT id FROM hierarchy)
|
| 221 |
-
GROUP BY parent_id
|
| 222 |
-
),
|
| 223 |
-
asset_attributes AS (
|
| 224 |
-
SELECT
|
| 225 |
-
h.id,
|
| 226 |
-
CASE
|
| 227 |
-
WHEN h.attributes IS NULL OR jsonb_typeof(h.attributes::jsonb) != 'object' THEN ARRAY[]::text[]
|
| 228 |
-
ELSE ARRAY(
|
| 229 |
-
SELECT key
|
| 230 |
-
FROM jsonb_object_keys(h.attributes::jsonb) AS key
|
| 231 |
-
ORDER BY key
|
| 232 |
-
)
|
| 233 |
-
END AS attribute_keys,
|
| 234 |
-
CASE
|
| 235 |
-
WHEN h.attributes IS NULL OR jsonb_typeof(h.attributes::jsonb) != 'object' THEN FALSE
|
| 236 |
-
ELSE ((h.attributes::jsonb - 'notes' - 'location') != '{{}}'::jsonb)
|
| 237 |
-
END AS is_device
|
| 238 |
-
FROM hierarchy h
|
| 239 |
-
)
|
| 240 |
-
SELECT
|
| 241 |
-
a.id::text AS db_asset_id,
|
| 242 |
-
a.name,
|
| 243 |
-
COALESCE(parent.name, '') AS parent_name,
|
| 244 |
-
COALESCE(cc.child_count, 0) AS child_count,
|
| 245 |
-
COALESCE(aa.is_device, FALSE) AS is_device,
|
| 246 |
-
COALESCE(aa.attribute_keys, ARRAY[]::text[]) AS attribute_keys,
|
| 247 |
-
(ld.entity_id IS NOT NULL) AS has_live_data,
|
| 248 |
-
COALESCE(
|
| 249 |
-
(
|
| 250 |
-
SELECT array_agg(DISTINCT key ORDER BY key)
|
| 251 |
-
FROM latest_data ld2,
|
| 252 |
-
LATERAL jsonb_object_keys(
|
| 253 |
-
CASE
|
| 254 |
-
WHEN jsonb_typeof(ld2.value::jsonb) = 'object' THEN ld2.value::jsonb
|
| 255 |
-
ELSE '{{}}'::jsonb
|
| 256 |
-
END
|
| 257 |
-
) AS key
|
| 258 |
-
WHERE ld2.entity_id = a.id
|
| 259 |
-
),
|
| 260 |
-
ARRAY[]::text[]
|
| 261 |
-
) AS data_keys
|
| 262 |
-
FROM hierarchy a
|
| 263 |
-
LEFT JOIN {self._settings.openremote_schema}.asset parent ON parent.id = a.parent_id
|
| 264 |
-
LEFT JOIN child_counts cc ON cc.parent_id = a.id
|
| 265 |
-
LEFT JOIN asset_attributes aa ON aa.id = a.id
|
| 266 |
-
LEFT JOIN latest_entities ld ON ld.entity_id = a.id
|
| 267 |
-
ORDER BY a.name;
|
| 268 |
-
""".strip()
|
| 269 |
-
rows = data_repository.execute_query(query, {"user_id": user_id})
|
| 270 |
-
return [self._merge_with_override(row) for row in rows]
|
| 271 |
-
|
| 272 |
-
def _merge_with_override(self, row: dict) -> AssetProfile:
|
| 273 |
-
name = str(row.get("name", ""))
|
| 274 |
-
override = asset_catalog_service.get_asset_by_name(name)
|
| 275 |
-
inferred_type = self._infer_asset_type(name, str(row.get("parent_name", "")), row.get("data_keys") or [])
|
| 276 |
-
is_device = bool(row.get("is_device"))
|
| 277 |
-
default_supported = self._default_supported_analyses(
|
| 278 |
-
inferred_type,
|
| 279 |
-
bool(row.get("has_live_data")),
|
| 280 |
-
is_device,
|
| 281 |
-
int(row.get("child_count", 0) or 0),
|
| 282 |
-
)
|
| 283 |
-
data_keys = [str(item) for item in (row.get("data_keys") or [])]
|
| 284 |
-
attribute_keys = [str(item) for item in (row.get("attribute_keys") or [])]
|
| 285 |
-
|
| 286 |
-
base = AssetProfile(
|
| 287 |
-
asset_id=str(row.get("db_asset_id", name)),
|
| 288 |
-
db_asset_id=str(row.get("db_asset_id", "")),
|
| 289 |
-
name=name,
|
| 290 |
-
parent_name=str(row.get("parent_name", "")),
|
| 291 |
-
child_count=int(row.get("child_count", 0) or 0),
|
| 292 |
-
is_device=is_device,
|
| 293 |
-
attribute_keys=attribute_keys,
|
| 294 |
-
has_live_data=bool(row.get("has_live_data")),
|
| 295 |
-
data_keys=data_keys,
|
| 296 |
-
asset_type=inferred_type,
|
| 297 |
-
description=self._build_description(
|
| 298 |
-
name,
|
| 299 |
-
str(row.get("parent_name", "")),
|
| 300 |
-
data_keys,
|
| 301 |
-
bool(row.get("has_live_data")),
|
| 302 |
-
is_device,
|
| 303 |
-
attribute_keys,
|
| 304 |
-
),
|
| 305 |
-
supported_analyses=default_supported,
|
| 306 |
-
metrics=data_keys[:12],
|
| 307 |
-
source_table=f"{self._settings.openremote_schema}.asset_datapoint",
|
| 308 |
-
analysis_instructions=self._build_analysis_instructions(
|
| 309 |
-
name,
|
| 310 |
-
str(row.get("parent_name", "")),
|
| 311 |
-
inferred_type,
|
| 312 |
-
data_keys,
|
| 313 |
-
bool(row.get("has_live_data")),
|
| 314 |
-
is_device,
|
| 315 |
-
int(row.get("child_count", 0) or 0),
|
| 316 |
-
),
|
| 317 |
-
lookup_names=[name],
|
| 318 |
-
)
|
| 319 |
-
|
| 320 |
-
if override is None:
|
| 321 |
-
return base
|
| 322 |
-
|
| 323 |
-
# Automatic key discovery
|
| 324 |
-
auto_temp = [k for k in data_keys if k.lower() in ("temperature", "temp", "t", "temp1")]
|
| 325 |
-
auto_humi = [k for k in data_keys if k.lower() in ("humidity", "humi", "h", "hum")]
|
| 326 |
-
auto_level = [k for k in data_keys if k.lower() in ("level", "tankoillevelinfeet001", "current_level")]
|
| 327 |
-
auto_prod = [k for k in data_keys if k.lower().startswith("prod_") or k.lower() == "total_count"]
|
| 328 |
-
|
| 329 |
-
merged = base.model_copy(update={
|
| 330 |
-
"asset_id": override.asset_id if override and override.asset_id else base.asset_id,
|
| 331 |
-
"asset_type": override.asset_type if override and override.asset_type else base.asset_type,
|
| 332 |
-
"description": override.description if override and override.description else base.description,
|
| 333 |
-
"supported_analyses": override.supported_analyses if override and override.supported_analyses else base.supported_analyses,
|
| 334 |
-
"metrics": override.metrics if override and override.metrics else base.metrics,
|
| 335 |
-
"analysis_instructions": override.analysis_instructions if override and override.analysis_instructions else base.analysis_instructions,
|
| 336 |
-
"sql_notes": override.sql_notes if override and override.sql_notes else base.sql_notes,
|
| 337 |
-
"lookup_names": sorted(set([base.name, *base.lookup_names, *(override.lookup_names if override else [])])),
|
| 338 |
-
"temperature_keys": override.temperature_keys if override and override.temperature_keys else auto_temp,
|
| 339 |
-
"humidity_keys": override.humidity_keys if override and override.humidity_keys else auto_humi,
|
| 340 |
-
"level_keys": override.level_keys if override and override.level_keys else auto_level,
|
| 341 |
-
"production_keys": override.production_keys if override and override.production_keys else auto_prod,
|
| 342 |
-
"nominal_range": override.nominal_range if override and override.nominal_range else base.nominal_range,
|
| 343 |
-
})
|
| 344 |
-
return merged
|
| 345 |
-
|
| 346 |
-
def _infer_asset_type(self, name: str, parent_name: str, data_keys: list[str]) -> str:
|
| 347 |
-
lowered = f"{name} {parent_name}".lower()
|
| 348 |
-
key_counts = Counter(key.lower() for key in data_keys)
|
| 349 |
-
|
| 350 |
-
# Prioritize tank detection to avoid "Water Tank" being called "cold_room"
|
| 351 |
-
if "tank" in lowered or {"tankoillevelinfeet001", "level"} & set(key_counts):
|
| 352 |
-
return "tank"
|
| 353 |
-
|
| 354 |
-
smoke_keys = {"warn", "temp", "humi", "bat_percent", "bat_voltage"}
|
| 355 |
-
if "tank level monitoring" in lowered:
|
| 356 |
-
return "tank"
|
| 357 |
-
if "weather station" in lowered:
|
| 358 |
-
return "weather_station"
|
| 359 |
-
if "smoke alarm" in lowered or smoke_keys & set(key_counts):
|
| 360 |
-
return "smoke_alarm"
|
| 361 |
-
if "coldroom" in lowered or {"temperature", "humidity"} & set(key_counts):
|
| 362 |
-
return "cold_room"
|
| 363 |
-
if "machine" in lowered or any("prod_" in key for key in key_counts):
|
| 364 |
-
return "filling_machine"
|
| 365 |
-
if "boiler" in lowered:
|
| 366 |
-
return "boiler"
|
| 367 |
-
if "energy meter" in lowered or "power meter" in lowered or {"voltage", "current", "power", "energy"} & set(key_counts):
|
| 368 |
-
return "energy_meter"
|
| 369 |
-
return "generic_asset"
|
| 370 |
-
|
| 371 |
-
def _default_supported_analyses(self, asset_type: str, has_live_data: bool, is_device: bool, child_count: int) -> list[str]:
|
| 372 |
-
if not is_device and child_count > 0:
|
| 373 |
-
return ["hierarchy_summary", "group_status", "group_comparison"]
|
| 374 |
-
if asset_type == "cold_room":
|
| 375 |
-
return ["live_status", "consumption_analysis", "cooling_cycle_analysis", "historical_variance", "anomaly_detection"]
|
| 376 |
-
if asset_type == "filling_machine":
|
| 377 |
-
return ["live_status", "throughput_utilization", "historical_variance", "anomaly_detection"]
|
| 378 |
-
if asset_type == "tank":
|
| 379 |
-
return ["live_status", "inventory_level_analysis", "trend_analysis", "anomaly_detection"]
|
| 380 |
-
if asset_type == "weather_station":
|
| 381 |
-
return ["live_status", "trend_analysis", "anomaly_detection"]
|
| 382 |
-
if asset_type == "energy_meter":
|
| 383 |
-
return ["live_status", "trend_analysis", "group_status", "historical_variance", "consumption_analysis"]
|
| 384 |
-
if asset_type == "smoke_alarm":
|
| 385 |
-
return ["live_status", "anomaly_detection", "group_status"]
|
| 386 |
-
if asset_type == "boiler":
|
| 387 |
-
return ["live_status", "trend_analysis", "historical_variance", "anomaly_detection"]
|
| 388 |
-
return ["live_status", "trend_analysis"] if has_live_data else ["hierarchy_summary"]
|
| 389 |
-
|
| 390 |
-
def _build_description(
|
| 391 |
-
self,
|
| 392 |
-
name: str,
|
| 393 |
-
parent_name: str,
|
| 394 |
-
data_keys: list[str],
|
| 395 |
-
has_live_data: bool,
|
| 396 |
-
is_device: bool,
|
| 397 |
-
attribute_keys: list[str],
|
| 398 |
-
) -> str:
|
| 399 |
-
role_text = "sensor device" if is_device else "category or grouping asset"
|
| 400 |
-
live_text = "has live telemetry data" if has_live_data else "does not appear to have direct live telemetry"
|
| 401 |
-
parent_text = f"under parent {parent_name}" if parent_name else "with no recorded parent in the current view"
|
| 402 |
-
attr_text = f"Attribute keys include: {', '.join(attribute_keys[:12])}." if attribute_keys else "No attribute keys were discovered."
|
| 403 |
-
keys_text = f"Available telemetry keys include: {', '.join(data_keys[:12])}." if data_keys else "No telemetry keys were discovered yet."
|
| 404 |
-
return f"{name} is an OpenRemote {role_text} {parent_text}. It {live_text}. {attr_text} {keys_text}"
|
| 405 |
-
|
| 406 |
-
def _build_analysis_instructions(
|
| 407 |
-
self,
|
| 408 |
-
name: str,
|
| 409 |
-
parent_name: str,
|
| 410 |
-
asset_type: str,
|
| 411 |
-
data_keys: list[str],
|
| 412 |
-
has_live_data: bool,
|
| 413 |
-
is_device: bool,
|
| 414 |
-
child_count: int,
|
| 415 |
-
) -> str:
|
| 416 |
-
if not is_device and child_count > 0:
|
| 417 |
-
return (
|
| 418 |
-
f"{name} is a category/group asset. It should be analyzed through its child assets. "
|
| 419 |
-
"If the user asks about this asset, first discover the relevant child devices under the hierarchy "
|
| 420 |
-
"and summarize those instead of expecting direct telemetry on the category itself."
|
| 421 |
-
)
|
| 422 |
-
if asset_type == "tank":
|
| 423 |
-
return (
|
| 424 |
-
f"Analyze {name} as a tank-type device. "
|
| 425 |
-
"For tank devices, actual physical liquid height is MaxFt - TankOilLevelInFeet001, "
|
| 426 |
-
"even when those values come from different attributes such as Data and Maxcapacity. "
|
| 427 |
-
"Use that derived height in the final analysis whenever those fields are available."
|
| 428 |
-
)
|
| 429 |
-
if asset_type == "cold_room" and "t & h monitoring" in f"{name} {parent_name}".lower():
|
| 430 |
-
return (
|
| 431 |
-
f"Analyze {name} as a T & H Monitoring cold-room device. "
|
| 432 |
-
"Treat the safe temperature threshold as 5C to 45C unless the user explicitly provides a stricter range. "
|
| 433 |
-
"Always provide consumption analysis for the requested time window by inferring ON/OFF state from temperature history. "
|
| 434 |
-
"A cooling cycle starts at the last temperature peak before a drop that later goes below 20C, "
|
| 435 |
-
"stays ON while temperature remains below 20C, and ends at the valley before temperature rises back to 20C or above. "
|
| 436 |
-
"Sum those cycle durations and clip any carry-over cycle to the 6:00 AM shift start."
|
| 437 |
-
)
|
| 438 |
-
if asset_type == "smoke_alarm":
|
| 439 |
-
return (
|
| 440 |
-
f"Analyze {name} as a smoke-alarm sensor or smoke-alarm group. "
|
| 441 |
-
"Inspect all JSON telemetry attributes for warn, temp, humi, bat_percent, and bat_voltage values. "
|
| 442 |
-
"Treat warn='warn' as smoke/fire detected, warn='mute' as alarm muted after fire detection, "
|
| 443 |
-
"warn='fault' as sensor fault, warn='remove' as sensor removal, warn='install' as sensor install event, "
|
| 444 |
-
"warn='low-vol' or warn='low-vol-test' as low-battery/voltage conditions, and warn='ok' as normal. "
|
| 445 |
-
"If the user asks about a category, summarize all child devices together and list affected devices with timestamps."
|
| 446 |
-
)
|
| 447 |
-
return (
|
| 448 |
-
f"Analyze {name} using the returned telemetry keys {', '.join(data_keys[:12])}. "
|
| 449 |
-
"Use the user's requested time range and produce a practical end-user summary."
|
| 450 |
-
)
|
| 451 |
-
|
| 452 |
-
def _normalize(self, value: str) -> str:
|
| 453 |
-
return "".join(value.lower().split())
|
| 454 |
-
|
| 455 |
-
def _tokenize(self, value: str) -> list[str]:
|
| 456 |
-
# Split alpha and numeric parts (e.g. ColdRoom1 -> Cold Room 1)
|
| 457 |
-
import re
|
| 458 |
-
text = str(value)
|
| 459 |
-
text = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', text)
|
| 460 |
-
text = re.sub(r'(\d)([a-zA-Z])', r'\1 \2', text)
|
| 461 |
-
cleaned = "".join(char if char.isalnum() else " " for char in text.lower())
|
| 462 |
-
return [token for token in cleaned.split() if token]
|
| 463 |
-
|
| 464 |
-
def _normalize_for_tokens(self, value: str) -> str:
|
| 465 |
-
# This is now handled inside _tokenize for better granularity
|
| 466 |
-
return " ".join(self._tokenize(value))
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
live_asset_registry = LiveAssetRegistry()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/services/llm_service.py
DELETED
|
@@ -1,310 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import json
|
| 4 |
-
import re
|
| 5 |
-
from collections import defaultdict
|
| 6 |
-
from pathlib import Path
|
| 7 |
-
from typing import Any
|
| 8 |
-
|
| 9 |
-
from langchain_core.prompts import ChatPromptTemplate
|
| 10 |
-
|
| 11 |
-
try:
|
| 12 |
-
from langchain_groq import ChatGroq
|
| 13 |
-
except ImportError: # pragma: no cover
|
| 14 |
-
ChatGroq = None
|
| 15 |
-
|
| 16 |
-
from agents.analysis.backend.app.core.config import get_settings
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
class LLMService:
|
| 20 |
-
def __init__(self) -> None:
|
| 21 |
-
self._settings = get_settings()
|
| 22 |
-
prompts_dir = Path(__file__).resolve().parents[1] / "prompts"
|
| 23 |
-
self._request_planner_path = prompts_dir / "request_planner.txt"
|
| 24 |
-
self._sql_generator_path = prompts_dir / "sql_generator.txt"
|
| 25 |
-
self._result_analyst_path = prompts_dir / "result_analyst.txt"
|
| 26 |
-
|
| 27 |
-
def plan_request(self, user_question: str, asset_catalog_context: str) -> dict[str, Any]:
|
| 28 |
-
fallback = {
|
| 29 |
-
"asset_id": "",
|
| 30 |
-
"analysis_name": "live_status",
|
| 31 |
-
"time_window": {
|
| 32 |
-
"scope": "historical",
|
| 33 |
-
"value": 1,
|
| 34 |
-
"unit": "hours",
|
| 35 |
-
"label": "last 1 hour",
|
| 36 |
-
},
|
| 37 |
-
"reasoning": "The planner could not run, so no asset was identified.",
|
| 38 |
-
"prompt_text": "",
|
| 39 |
-
}
|
| 40 |
-
system_prompt = self._request_planner_path.read_text(encoding="utf-8")
|
| 41 |
-
human_prompt = (
|
| 42 |
-
"User request:\n{user_question}\n\n"
|
| 43 |
-
"Asset catalog:\n{asset_catalog_context}\n"
|
| 44 |
-
)
|
| 45 |
-
variables = {
|
| 46 |
-
"user_question": user_question,
|
| 47 |
-
"asset_catalog_context": self._clip_text(asset_catalog_context, 2200),
|
| 48 |
-
}
|
| 49 |
-
result = self._invoke_json(
|
| 50 |
-
system_prompt=system_prompt,
|
| 51 |
-
human_prompt=human_prompt,
|
| 52 |
-
variables=variables,
|
| 53 |
-
fallback=fallback,
|
| 54 |
-
)
|
| 55 |
-
result["prompt_text"] = self._render_prompt_text(system_prompt, human_prompt, variables)
|
| 56 |
-
return result
|
| 57 |
-
|
| 58 |
-
def generate_sql(
|
| 59 |
-
self,
|
| 60 |
-
user_question: str,
|
| 61 |
-
asset_context: str,
|
| 62 |
-
analysis_name: str,
|
| 63 |
-
time_window_label: str,
|
| 64 |
-
) -> dict[str, Any]:
|
| 65 |
-
system_prompt = self._sql_generator_path.read_text(encoding="utf-8")
|
| 66 |
-
human_prompt = (
|
| 67 |
-
"User request:\n{user_question}\n\n"
|
| 68 |
-
"Chosen analysis:\n{analysis_name}\n\n"
|
| 69 |
-
"Chosen time window:\n{time_window_label}\n\n"
|
| 70 |
-
"Asset context:\n{asset_context}\n"
|
| 71 |
-
)
|
| 72 |
-
variables = {
|
| 73 |
-
"user_question": user_question,
|
| 74 |
-
"analysis_name": analysis_name,
|
| 75 |
-
"time_window_label": time_window_label,
|
| 76 |
-
"asset_context": self._clip_text(asset_context, 2600),
|
| 77 |
-
}
|
| 78 |
-
raw_response, total_tokens = self._invoke_text(
|
| 79 |
-
system_prompt=system_prompt,
|
| 80 |
-
human_prompt=human_prompt,
|
| 81 |
-
variables=variables,
|
| 82 |
-
)
|
| 83 |
-
return {
|
| 84 |
-
"sql_query": self._strip_code_fences(raw_response),
|
| 85 |
-
"total_tokens": total_tokens,
|
| 86 |
-
"raw_response": raw_response,
|
| 87 |
-
"prompt_text": self._render_prompt_text(system_prompt, human_prompt, variables),
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
def analyze_result(
|
| 91 |
-
self,
|
| 92 |
-
user_question: str,
|
| 93 |
-
asset_context: str,
|
| 94 |
-
sql_query: str,
|
| 95 |
-
deterministic_findings: dict[str, Any],
|
| 96 |
-
rows: list[dict],
|
| 97 |
-
) -> dict[str, Any]:
|
| 98 |
-
fallback = (
|
| 99 |
-
"I retrieved the requested data, but the final LLM analysis could not be generated. "
|
| 100 |
-
"Please review the SQL and database rows in the details panel."
|
| 101 |
-
)
|
| 102 |
-
system_prompt = self._result_analyst_path.read_text(encoding="utf-8")
|
| 103 |
-
human_prompt = (
|
| 104 |
-
"User request:\n{user_question}\n\n"
|
| 105 |
-
"Asset context:\n{asset_context}\n\n"
|
| 106 |
-
"SQL query used:\n{sql_query}\n\n"
|
| 107 |
-
"Deterministic findings:\n{deterministic_findings}\n\n"
|
| 108 |
-
"Returned rows:\n{rows}\n"
|
| 109 |
-
)
|
| 110 |
-
variables = {
|
| 111 |
-
"user_question": user_question,
|
| 112 |
-
"asset_context": self._clip_text(asset_context, 2400),
|
| 113 |
-
"sql_query": self._clip_text(sql_query, 2200),
|
| 114 |
-
"deterministic_findings": self._compact_findings(deterministic_findings),
|
| 115 |
-
"rows": self._compact_rows(rows),
|
| 116 |
-
}
|
| 117 |
-
raw_response, total_tokens = self._invoke_text(
|
| 118 |
-
system_prompt=system_prompt,
|
| 119 |
-
human_prompt=human_prompt,
|
| 120 |
-
variables=variables,
|
| 121 |
-
)
|
| 122 |
-
text = raw_response.strip() or fallback
|
| 123 |
-
return {
|
| 124 |
-
"answer": text,
|
| 125 |
-
"total_tokens": total_tokens,
|
| 126 |
-
"raw_response": raw_response,
|
| 127 |
-
"prompt_text": self._render_prompt_text(system_prompt, human_prompt, variables),
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
def _invoke_json(
|
| 131 |
-
self,
|
| 132 |
-
system_prompt: str,
|
| 133 |
-
human_prompt: str,
|
| 134 |
-
variables: dict[str, Any],
|
| 135 |
-
fallback: dict[str, Any],
|
| 136 |
-
) -> dict[str, Any]:
|
| 137 |
-
raw_response, _ = self._invoke_text(system_prompt, human_prompt, variables)
|
| 138 |
-
try:
|
| 139 |
-
return json.loads(self._extract_json_object(raw_response))
|
| 140 |
-
except Exception:
|
| 141 |
-
return fallback
|
| 142 |
-
|
| 143 |
-
def _invoke_text(
|
| 144 |
-
self,
|
| 145 |
-
system_prompt: str,
|
| 146 |
-
human_prompt: str,
|
| 147 |
-
variables: dict[str, Any],
|
| 148 |
-
) -> tuple[str, int]:
|
| 149 |
-
if not self._settings.groq_api_key or ChatGroq is None:
|
| 150 |
-
return "", 0
|
| 151 |
-
|
| 152 |
-
prompt = ChatPromptTemplate.from_messages(
|
| 153 |
-
[
|
| 154 |
-
("system", system_prompt),
|
| 155 |
-
("human", human_prompt),
|
| 156 |
-
]
|
| 157 |
-
)
|
| 158 |
-
|
| 159 |
-
llm = ChatGroq(
|
| 160 |
-
api_key=self._settings.groq_api_key,
|
| 161 |
-
model=self._settings.groq_model,
|
| 162 |
-
temperature=0.1,
|
| 163 |
-
)
|
| 164 |
-
chain = prompt | llm
|
| 165 |
-
response = chain.invoke(variables)
|
| 166 |
-
raw_content = response.content if isinstance(response.content, str) else str(response.content)
|
| 167 |
-
return raw_content, self._extract_total_tokens(response)
|
| 168 |
-
|
| 169 |
-
def _extract_total_tokens(self, response: Any) -> int:
|
| 170 |
-
usage_metadata = getattr(response, "usage_metadata", None) or {}
|
| 171 |
-
response_metadata = getattr(response, "response_metadata", None) or {}
|
| 172 |
-
token_usage = response_metadata.get("token_usage", {})
|
| 173 |
-
|
| 174 |
-
if isinstance(usage_metadata, dict) and usage_metadata.get("total_tokens") is not None:
|
| 175 |
-
return int(usage_metadata.get("total_tokens") or 0)
|
| 176 |
-
if isinstance(token_usage, dict) and token_usage.get("total_tokens") is not None:
|
| 177 |
-
return int(token_usage.get("total_tokens") or 0)
|
| 178 |
-
return 0
|
| 179 |
-
|
| 180 |
-
def _extract_json_object(self, text: str) -> str:
|
| 181 |
-
fenced = re.search(r"```json\s*(\{.*\})\s*```", text, re.DOTALL)
|
| 182 |
-
if fenced:
|
| 183 |
-
return fenced.group(1)
|
| 184 |
-
|
| 185 |
-
direct = re.search(r"(\{.*\})", text, re.DOTALL)
|
| 186 |
-
if direct:
|
| 187 |
-
return direct.group(1)
|
| 188 |
-
|
| 189 |
-
raise ValueError("No JSON object found in LLM response.")
|
| 190 |
-
|
| 191 |
-
def _strip_code_fences(self, text: str) -> str:
|
| 192 |
-
cleaned = text.strip()
|
| 193 |
-
cleaned = re.sub(r"^```sql\s*", "", cleaned, flags=re.IGNORECASE)
|
| 194 |
-
cleaned = re.sub(r"^```\s*", "", cleaned)
|
| 195 |
-
cleaned = re.sub(r"\s*```$", "", cleaned)
|
| 196 |
-
return cleaned.strip()
|
| 197 |
-
|
| 198 |
-
def _render_prompt_text(
|
| 199 |
-
self,
|
| 200 |
-
system_prompt: str,
|
| 201 |
-
human_prompt: str,
|
| 202 |
-
variables: dict[str, Any],
|
| 203 |
-
) -> str:
|
| 204 |
-
rendered_human = human_prompt.format(**variables)
|
| 205 |
-
return f"[System Prompt]\n{system_prompt}\n\n[Human Prompt]\n{rendered_human}"
|
| 206 |
-
|
| 207 |
-
def _clip_text(self, value: str, max_chars: int) -> str:
|
| 208 |
-
text = (value or "").strip()
|
| 209 |
-
if len(text) <= max_chars:
|
| 210 |
-
return text
|
| 211 |
-
return f"{text[:max_chars]} ...[truncated]"
|
| 212 |
-
|
| 213 |
-
def _compact_findings(self, findings: dict[str, Any]) -> str:
|
| 214 |
-
if not isinstance(findings, dict):
|
| 215 |
-
return self._clip_text(json.dumps(findings, default=str), 1200)
|
| 216 |
-
compact = {
|
| 217 |
-
"status": findings.get("status", ""),
|
| 218 |
-
"overview": findings.get("overview", ""),
|
| 219 |
-
"findings": findings.get("findings", [])[:12],
|
| 220 |
-
"warnings": findings.get("warnings", [])[:10],
|
| 221 |
-
"numeric_metrics": findings.get("numeric_metrics", {}),
|
| 222 |
-
"asset_specific": findings.get("asset_specific", {}),
|
| 223 |
-
"table_excerpt": self._clip_text(str(findings.get("table", "")), 600),
|
| 224 |
-
}
|
| 225 |
-
return self._clip_text(json.dumps(compact, default=str), 1800)
|
| 226 |
-
|
| 227 |
-
def _compact_rows(self, rows: list[dict[str, Any]]) -> str:
|
| 228 |
-
if not rows:
|
| 229 |
-
return "[]"
|
| 230 |
-
|
| 231 |
-
# Keep representative context across child assets instead of only first rows.
|
| 232 |
-
by_asset: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
| 233 |
-
for row in rows:
|
| 234 |
-
asset_name = str(
|
| 235 |
-
row.get("asset_name")
|
| 236 |
-
or row.get("name")
|
| 237 |
-
or row.get("device_name")
|
| 238 |
-
or "unknown_asset"
|
| 239 |
-
)
|
| 240 |
-
by_asset[asset_name].append(row)
|
| 241 |
-
|
| 242 |
-
selected_rows: list[dict[str, Any]] = []
|
| 243 |
-
# Include latest row per asset first (group-wide visibility).
|
| 244 |
-
for asset_name in sorted(by_asset.keys()):
|
| 245 |
-
selected_rows.append(by_asset[asset_name][-1])
|
| 246 |
-
|
| 247 |
-
# Include start-of-window row per asset to capture full-day behavior.
|
| 248 |
-
for asset_name in sorted(by_asset.keys()):
|
| 249 |
-
selected_rows.append(by_asset[asset_name][0])
|
| 250 |
-
|
| 251 |
-
# Then include one midpoint row per asset when available.
|
| 252 |
-
for asset_name in sorted(by_asset.keys()):
|
| 253 |
-
entries = by_asset[asset_name]
|
| 254 |
-
if len(entries) >= 3:
|
| 255 |
-
selected_rows.append(entries[len(entries) // 2])
|
| 256 |
-
if len(selected_rows) >= 60:
|
| 257 |
-
break
|
| 258 |
-
|
| 259 |
-
compacted: list[dict[str, Any]] = []
|
| 260 |
-
important_keys = [
|
| 261 |
-
"asset_name",
|
| 262 |
-
"device_name",
|
| 263 |
-
"name",
|
| 264 |
-
"attribute_name",
|
| 265 |
-
"sensor_name",
|
| 266 |
-
"recorded_at",
|
| 267 |
-
"timestamp",
|
| 268 |
-
"latest_time",
|
| 269 |
-
"warn_type",
|
| 270 |
-
"current_level_ft",
|
| 271 |
-
"percentage_filled",
|
| 272 |
-
"tank_oil_level_in_feet_001",
|
| 273 |
-
"max_ft",
|
| 274 |
-
"temperature",
|
| 275 |
-
"humidity",
|
| 276 |
-
"bat_percent",
|
| 277 |
-
"bat_voltage",
|
| 278 |
-
"latest_temp",
|
| 279 |
-
"latest_humidity",
|
| 280 |
-
"lowest_temp",
|
| 281 |
-
"highest_temp",
|
| 282 |
-
"total_count",
|
| 283 |
-
"shift_1_count",
|
| 284 |
-
"shift_2_count",
|
| 285 |
-
"shift_3_count",
|
| 286 |
-
"status",
|
| 287 |
-
"is_stale",
|
| 288 |
-
"is_heartbeat_stale",
|
| 289 |
-
"is_payload_stale",
|
| 290 |
-
"level_change",
|
| 291 |
-
"cooling_on_minutes",
|
| 292 |
-
]
|
| 293 |
-
|
| 294 |
-
for row in selected_rows[:60]:
|
| 295 |
-
compact_row: dict[str, Any] = {}
|
| 296 |
-
for key in important_keys:
|
| 297 |
-
if key not in row:
|
| 298 |
-
continue
|
| 299 |
-
value = row.get(key)
|
| 300 |
-
if isinstance(value, (list, dict)):
|
| 301 |
-
compact_row[key] = self._clip_text(json.dumps(value, default=str), 140)
|
| 302 |
-
else:
|
| 303 |
-
compact_row[key] = self._clip_text(str(value), 140)
|
| 304 |
-
if compact_row:
|
| 305 |
-
compacted.append(compact_row)
|
| 306 |
-
|
| 307 |
-
return self._clip_text(json.dumps(compacted, default=str), 3600)
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
llm_service = LLMService()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/services/nl_to_sql.py
DELETED
|
@@ -1,1553 +0,0 @@
|
|
| 1 |
-
import contextvars
|
| 2 |
-
from dataclasses import dataclass
|
| 3 |
-
from datetime import datetime
|
| 4 |
-
|
| 5 |
-
from agents.analysis.backend.app.core.config import get_settings
|
| 6 |
-
from agents.analysis.backend.app.models.schemas import AssetProfile, TimeWindow
|
| 7 |
-
from agents.analysis.backend.app.services.sql_guard import validate_read_only_sql
|
| 8 |
-
|
| 9 |
-
_schema_context = contextvars.ContextVar("schema_context", default=None)
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
@dataclass
|
| 13 |
-
class SQLPlan:
|
| 14 |
-
query: str
|
| 15 |
-
explanation: str
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
class NLToSQLService:
|
| 19 |
-
def __init__(self) -> None:
|
| 20 |
-
self._settings = get_settings()
|
| 21 |
-
|
| 22 |
-
def _get_schema(self) -> str:
|
| 23 |
-
ctx_val = _schema_context.get()
|
| 24 |
-
return ctx_val or self._settings.openremote_schema
|
| 25 |
-
|
| 26 |
-
def build_query(
|
| 27 |
-
self,
|
| 28 |
-
asset: AssetProfile,
|
| 29 |
-
question: str,
|
| 30 |
-
analysis_name: str,
|
| 31 |
-
time_window: TimeWindow,
|
| 32 |
-
schema_name: str | None = None,
|
| 33 |
-
) -> SQLPlan:
|
| 34 |
-
token = _schema_context.set(schema_name)
|
| 35 |
-
try:
|
| 36 |
-
query = self._build_asset_query(asset=asset, analysis_name=analysis_name, time_window=time_window)
|
| 37 |
-
validate_read_only_sql(query)
|
| 38 |
-
|
| 39 |
-
explanation = (
|
| 40 |
-
f"Generated a read-only OpenRemote query for `{asset.name}` using "
|
| 41 |
-
f"`{analysis_name}` over {time_window.label}."
|
| 42 |
-
)
|
| 43 |
-
return SQLPlan(query=query, explanation=explanation)
|
| 44 |
-
finally:
|
| 45 |
-
_schema_context.reset(token)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def normalize_generated_query(self, query: str, asset: AssetProfile) -> str:
|
| 49 |
-
normalized = query
|
| 50 |
-
normalized = normalized.replace("ad.asset_id", "ad.entity_id")
|
| 51 |
-
normalized = normalized.replace("ad.AssetId", "ad.entity_id")
|
| 52 |
-
normalized = normalized.replace("asset_datapoint.asset_id", "asset_datapoint.entity_id")
|
| 53 |
-
|
| 54 |
-
if "JOIN openremote.asset_datapoint ad ON ad.entity_id = a.id" not in normalized:
|
| 55 |
-
normalized = normalized.replace(
|
| 56 |
-
"JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id",
|
| 57 |
-
"JOIN openremote.asset_datapoint ad ON ad.entity_id = a.id",
|
| 58 |
-
)
|
| 59 |
-
|
| 60 |
-
stripped = normalized.lstrip()
|
| 61 |
-
if "descendants AS" in normalized and stripped.startswith("WITH ") and not stripped.startswith("WITH RECURSIVE "):
|
| 62 |
-
normalized = normalized.replace("WITH ", "WITH RECURSIVE ", 1)
|
| 63 |
-
|
| 64 |
-
return normalized
|
| 65 |
-
|
| 66 |
-
def _build_asset_query(self, asset: AssetProfile, analysis_name: str, time_window: TimeWindow) -> str:
|
| 67 |
-
if not asset.is_device and asset.child_count > 0:
|
| 68 |
-
if asset.asset_type == "tank":
|
| 69 |
-
return self._build_tank_group_query(asset, time_window)
|
| 70 |
-
if asset.asset_type == "smoke_alarm":
|
| 71 |
-
return self._build_smoke_group_query(asset, time_window)
|
| 72 |
-
if asset.asset_type == "energy_meter":
|
| 73 |
-
return self._build_energy_group_query(asset, time_window)
|
| 74 |
-
if asset.asset_type == "aqi":
|
| 75 |
-
return self._build_aqi_group_query(asset, time_window)
|
| 76 |
-
return self._build_group_query(asset, time_window)
|
| 77 |
-
if asset.asset_type == "cold_room":
|
| 78 |
-
return self._build_cold_room_query(asset, time_window)
|
| 79 |
-
if asset.asset_type == "filling_machine":
|
| 80 |
-
return self._build_filling_machine_query(asset, time_window)
|
| 81 |
-
if asset.asset_type == "tank":
|
| 82 |
-
return self._build_tank_query(asset, time_window)
|
| 83 |
-
if asset.asset_type == "smoke_alarm":
|
| 84 |
-
return self._build_smoke_device_query(asset, time_window)
|
| 85 |
-
return self._build_generic_query(asset, time_window)
|
| 86 |
-
|
| 87 |
-
def _build_smoke_group_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
|
| 88 |
-
root_id = self._escape_sql(asset.db_asset_id or asset.asset_id)
|
| 89 |
-
offset = self._settings.local_time_offset_hours
|
| 90 |
-
timestamp_window_filter = self._window_filter_sql("ad.timestamp", time_window, local=False)
|
| 91 |
-
sampled = self._is_multi_day_window(time_window)
|
| 92 |
-
per_day_points = self._points_per_day(time_window)
|
| 93 |
-
if sampled:
|
| 94 |
-
return f"""
|
| 95 |
-
WITH RECURSIVE hierarchy AS (
|
| 96 |
-
SELECT id, name, parent_id, attributes::jsonb AS attributes, attributes::jsonb AS static_attributes
|
| 97 |
-
FROM {self._get_schema()}.asset
|
| 98 |
-
WHERE id = '{root_id}'
|
| 99 |
-
UNION ALL
|
| 100 |
-
SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes, c.attributes::jsonb AS static_attributes
|
| 101 |
-
FROM {self._get_schema()}.asset c
|
| 102 |
-
JOIN hierarchy h ON c.parent_id = h.id
|
| 103 |
-
),
|
| 104 |
-
devices AS (
|
| 105 |
-
SELECT id, name, static_attributes
|
| 106 |
-
FROM hierarchy
|
| 107 |
-
WHERE id != '{root_id}'
|
| 108 |
-
),
|
| 109 |
-
raw_points AS (
|
| 110 |
-
SELECT
|
| 111 |
-
d.name AS asset_name,
|
| 112 |
-
d.static_attributes,
|
| 113 |
-
ad.attribute_name,
|
| 114 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
|
| 115 |
-
ad.value AS sensor_data,
|
| 116 |
-
LOWER(COALESCE(ad.value->>'warn', CASE WHEN ad.attribute_name ILIKE '%warn%' OR ad.attribute_name ILIKE '%smoke%' THEN ad.value::text END, '')) AS warn_raw,
|
| 117 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'temp', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS temperature,
|
| 118 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'humi', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS humidity,
|
| 119 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'bat_percent', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_percent,
|
| 120 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'bat_voltage', CASE WHEN ad.attribute_name ILIKE '%voltage%' THEN ad.value::text END, ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_voltage,
|
| 121 |
-
CASE
|
| 122 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', CASE WHEN ad.attribute_name ILIKE '%warn%' OR ad.attribute_name ILIKE '%smoke%' THEN ad.value::text END, '')) IN ('warn', 'mute', '11', '0x11', '12', '0x12', '1', 'true', 'on', 'alarm') THEN 1
|
| 123 |
-
ELSE 0
|
| 124 |
-
END AS smoke_detected,
|
| 125 |
-
COALESCE(ad.value->>'name', ad.attribute_name) AS sensor_name,
|
| 126 |
-
date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
|
| 127 |
-
FROM devices d
|
| 128 |
-
LEFT JOIN {self._get_schema()}.asset_datapoint ad ON ad.entity_id = d.id
|
| 129 |
-
WHERE 1=1
|
| 130 |
-
{timestamp_window_filter}
|
| 131 |
-
),
|
| 132 |
-
bucketed AS (
|
| 133 |
-
SELECT
|
| 134 |
-
rp.*,
|
| 135 |
-
ntile({per_day_points}) OVER (
|
| 136 |
-
PARTITION BY rp.asset_name, rp.attribute_name, rp.day_bucket
|
| 137 |
-
ORDER BY rp.timestamp
|
| 138 |
-
) AS sample_bucket
|
| 139 |
-
FROM raw_points rp
|
| 140 |
-
),
|
| 141 |
-
sampled_points AS (
|
| 142 |
-
SELECT *
|
| 143 |
-
FROM (
|
| 144 |
-
SELECT
|
| 145 |
-
b.*,
|
| 146 |
-
row_number() OVER (
|
| 147 |
-
PARTITION BY b.asset_name, b.attribute_name, b.day_bucket, b.sample_bucket
|
| 148 |
-
ORDER BY b.timestamp DESC
|
| 149 |
-
) AS rn
|
| 150 |
-
FROM bucketed b
|
| 151 |
-
) ranked
|
| 152 |
-
WHERE rn = 1
|
| 153 |
-
)
|
| 154 |
-
SELECT
|
| 155 |
-
asset_name,
|
| 156 |
-
static_attributes,
|
| 157 |
-
attribute_name,
|
| 158 |
-
timestamp AS recorded_at,
|
| 159 |
-
sensor_name,
|
| 160 |
-
CASE
|
| 161 |
-
WHEN warn_raw IN ('warn', 'mute', 'low-vol', 'fault', 'ok', 'remove', 'install', 'ok-vol-test', 'low-vol-test') THEN warn_raw
|
| 162 |
-
WHEN warn_raw IN ('17', '0x17') THEN 'ok'
|
| 163 |
-
WHEN warn_raw IN ('11', '0x11') THEN 'warn'
|
| 164 |
-
WHEN warn_raw IN ('12', '0x12') THEN 'mute'
|
| 165 |
-
WHEN warn_raw IN ('14', '0x14') THEN 'low-vol'
|
| 166 |
-
WHEN warn_raw IN ('15', '0x15') THEN 'fault'
|
| 167 |
-
WHEN warn_raw IN ('1a', '0x1a', '26') THEN 'remove'
|
| 168 |
-
WHEN warn_raw IN ('1b', '0x1b', '27') THEN 'install'
|
| 169 |
-
WHEN warn_raw IN ('1e', '0x1e', '30') THEN 'ok-vol-test'
|
| 170 |
-
WHEN warn_raw IN ('1f', '0x1f', '31') THEN 'low-vol-test'
|
| 171 |
-
WHEN warn_raw = '' THEN NULL
|
| 172 |
-
ELSE 'unknown'
|
| 173 |
-
END AS warn_type,
|
| 174 |
-
temperature,
|
| 175 |
-
humidity,
|
| 176 |
-
bat_percent,
|
| 177 |
-
bat_voltage,
|
| 178 |
-
smoke_detected,
|
| 179 |
-
sensor_data
|
| 180 |
-
FROM sampled_points
|
| 181 |
-
ORDER BY asset_name, recorded_at ASC, attribute_name;
|
| 182 |
-
""".strip()
|
| 183 |
-
return f"""
|
| 184 |
-
WITH RECURSIVE hierarchy AS (
|
| 185 |
-
SELECT id, name, parent_id, attributes::jsonb AS attributes, attributes::jsonb AS static_attributes
|
| 186 |
-
FROM {self._get_schema()}.asset
|
| 187 |
-
WHERE id = '{root_id}'
|
| 188 |
-
UNION ALL
|
| 189 |
-
SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes, c.attributes::jsonb AS static_attributes
|
| 190 |
-
FROM {self._get_schema()}.asset c
|
| 191 |
-
JOIN hierarchy h ON c.parent_id = h.id
|
| 192 |
-
),
|
| 193 |
-
devices AS (
|
| 194 |
-
SELECT id, name, static_attributes
|
| 195 |
-
FROM hierarchy
|
| 196 |
-
WHERE id != '{root_id}'
|
| 197 |
-
),
|
| 198 |
-
raw_points AS (
|
| 199 |
-
SELECT
|
| 200 |
-
d.name AS asset_name,
|
| 201 |
-
d.static_attributes,
|
| 202 |
-
ad.attribute_name,
|
| 203 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
|
| 204 |
-
ad.value AS sensor_data,
|
| 205 |
-
LOWER(COALESCE(ad.value->>'warn', '')) AS warn_raw,
|
| 206 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'temp', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS temperature,
|
| 207 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'humi', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS humidity,
|
| 208 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'bat_percent', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_percent,
|
| 209 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'bat_voltage', CASE WHEN ad.attribute_name ILIKE '%voltage%' THEN ad.value::text END, ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_voltage,
|
| 210 |
-
CASE
|
| 211 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', CASE WHEN ad.attribute_name ILIKE '%warn%' OR ad.attribute_name ILIKE '%smoke%' THEN ad.value::text END, '')) IN ('warn', 'mute', '11', '0x11', '12', '0x12', '1', 'true', 'on', 'alarm') THEN 1
|
| 212 |
-
ELSE 0
|
| 213 |
-
END AS smoke_detected,
|
| 214 |
-
COALESCE(ad.value->>'name', ad.attribute_name) AS sensor_name
|
| 215 |
-
FROM devices d
|
| 216 |
-
LEFT JOIN {self._get_schema()}.asset_datapoint ad ON ad.entity_id = d.id
|
| 217 |
-
WHERE 1=1
|
| 218 |
-
{timestamp_window_filter}
|
| 219 |
-
)
|
| 220 |
-
SELECT
|
| 221 |
-
asset_name,
|
| 222 |
-
static_attributes,
|
| 223 |
-
attribute_name,
|
| 224 |
-
timestamp AS recorded_at,
|
| 225 |
-
sensor_name,
|
| 226 |
-
CASE
|
| 227 |
-
WHEN warn_raw IN ('warn', 'mute', 'low-vol', 'fault', 'ok', 'remove', 'install', 'ok-vol-test', 'low-vol-test') THEN warn_raw
|
| 228 |
-
WHEN warn_raw IN ('17', '0x17') THEN 'ok'
|
| 229 |
-
WHEN warn_raw IN ('11', '0x11') THEN 'warn'
|
| 230 |
-
WHEN warn_raw IN ('12', '0x12') THEN 'mute'
|
| 231 |
-
WHEN warn_raw IN ('14', '0x14') THEN 'low-vol'
|
| 232 |
-
WHEN warn_raw IN ('15', '0x15') THEN 'fault'
|
| 233 |
-
WHEN warn_raw IN ('1a', '0x1a', '26') THEN 'remove'
|
| 234 |
-
WHEN warn_raw IN ('1b', '0x1b', '27') THEN 'install'
|
| 235 |
-
WHEN warn_raw IN ('1e', '0x1e', '30') THEN 'ok-vol-test'
|
| 236 |
-
WHEN warn_raw IN ('1f', '0x1f', '31') THEN 'low-vol-test'
|
| 237 |
-
WHEN warn_raw = '' THEN NULL
|
| 238 |
-
ELSE 'unknown'
|
| 239 |
-
END AS warn_type,
|
| 240 |
-
temperature,
|
| 241 |
-
humidity,
|
| 242 |
-
bat_percent,
|
| 243 |
-
bat_voltage,
|
| 244 |
-
smoke_detected,
|
| 245 |
-
sensor_data
|
| 246 |
-
FROM raw_points
|
| 247 |
-
ORDER BY asset_name, recorded_at ASC, attribute_name;
|
| 248 |
-
""".strip()
|
| 249 |
-
|
| 250 |
-
def _build_smoke_device_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
|
| 251 |
-
exact_match = self._exact_asset_match_sql(asset.name)
|
| 252 |
-
offset = self._settings.local_time_offset_hours
|
| 253 |
-
timestamp_window_filter = self._window_filter_sql("ad.timestamp", time_window, local=False)
|
| 254 |
-
sampled = self._is_multi_day_window(time_window)
|
| 255 |
-
per_day_points = self._points_per_day(time_window)
|
| 256 |
-
if sampled:
|
| 257 |
-
return f"""
|
| 258 |
-
WITH raw_points AS (
|
| 259 |
-
SELECT
|
| 260 |
-
a.id::text AS asset_id,
|
| 261 |
-
a.name AS asset_name,
|
| 262 |
-
a.attributes::jsonb AS static_attributes,
|
| 263 |
-
ad.attribute_name,
|
| 264 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
|
| 265 |
-
ad.value AS sensor_data,
|
| 266 |
-
LOWER(COALESCE(ad.value->>'warn', '')) AS warn_raw,
|
| 267 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'temp', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS temperature,
|
| 268 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'humi', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS humidity,
|
| 269 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'bat_percent', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_percent,
|
| 270 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'bat_voltage', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_voltage,
|
| 271 |
-
CASE
|
| 272 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('warn', 'mute', '11', '0x11', '12', '0x12', '1', 'true') THEN 1
|
| 273 |
-
ELSE 0
|
| 274 |
-
END AS smoke_detected,
|
| 275 |
-
COALESCE(ad.value->>'name', ad.attribute_name) AS sensor_name,
|
| 276 |
-
date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
|
| 277 |
-
FROM {self._get_schema()}.asset a
|
| 278 |
-
JOIN {self._get_schema()}.asset_datapoint ad ON ad.entity_id = a.id
|
| 279 |
-
WHERE {exact_match}
|
| 280 |
-
{timestamp_window_filter}
|
| 281 |
-
),
|
| 282 |
-
bucketed AS (
|
| 283 |
-
SELECT
|
| 284 |
-
rp.*,
|
| 285 |
-
ntile({per_day_points}) OVER (
|
| 286 |
-
PARTITION BY rp.asset_id, rp.attribute_name, rp.day_bucket
|
| 287 |
-
ORDER BY rp.timestamp
|
| 288 |
-
) AS sample_bucket
|
| 289 |
-
FROM raw_points rp
|
| 290 |
-
),
|
| 291 |
-
sampled_points AS (
|
| 292 |
-
SELECT *
|
| 293 |
-
FROM (
|
| 294 |
-
SELECT
|
| 295 |
-
b.*,
|
| 296 |
-
row_number() OVER (
|
| 297 |
-
PARTITION BY b.asset_id, b.attribute_name, b.day_bucket, b.sample_bucket
|
| 298 |
-
ORDER BY b.timestamp DESC
|
| 299 |
-
) AS rn
|
| 300 |
-
FROM bucketed b
|
| 301 |
-
) ranked
|
| 302 |
-
WHERE rn = 1
|
| 303 |
-
)
|
| 304 |
-
SELECT
|
| 305 |
-
asset_id,
|
| 306 |
-
asset_name,
|
| 307 |
-
static_attributes,
|
| 308 |
-
attribute_name,
|
| 309 |
-
timestamp AS recorded_at,
|
| 310 |
-
sensor_name,
|
| 311 |
-
CASE
|
| 312 |
-
WHEN warn_raw IN ('warn', 'mute', 'low-vol', 'fault', 'ok', 'remove', 'install', 'ok-vol-test', 'low-vol-test') THEN warn_raw
|
| 313 |
-
WHEN warn_raw IN ('17', '0x17') THEN 'ok'
|
| 314 |
-
WHEN warn_raw IN ('11', '0x11') THEN 'warn'
|
| 315 |
-
WHEN warn_raw IN ('12', '0x12') THEN 'mute'
|
| 316 |
-
WHEN warn_raw IN ('14', '0x14') THEN 'low-vol'
|
| 317 |
-
WHEN warn_raw IN ('15', '0x15') THEN 'fault'
|
| 318 |
-
WHEN warn_raw IN ('1a', '0x1a', '26') THEN 'remove'
|
| 319 |
-
WHEN warn_raw IN ('1b', '0x1b', '27') THEN 'install'
|
| 320 |
-
WHEN warn_raw IN ('1e', '0x1e', '30') THEN 'ok-vol-test'
|
| 321 |
-
WHEN warn_raw IN ('1f', '0x1f', '31') THEN 'low-vol-test'
|
| 322 |
-
WHEN warn_raw = '' THEN NULL
|
| 323 |
-
ELSE 'unknown'
|
| 324 |
-
END AS warn_type,
|
| 325 |
-
temperature,
|
| 326 |
-
humidity,
|
| 327 |
-
bat_percent,
|
| 328 |
-
bat_voltage,
|
| 329 |
-
smoke_detected,
|
| 330 |
-
sensor_data
|
| 331 |
-
FROM sampled_points
|
| 332 |
-
ORDER BY recorded_at ASC, attribute_name;
|
| 333 |
-
""".strip()
|
| 334 |
-
return f"""
|
| 335 |
-
SELECT
|
| 336 |
-
a.id::text AS asset_id,
|
| 337 |
-
a.name AS asset_name,
|
| 338 |
-
a.attributes::jsonb AS static_attributes,
|
| 339 |
-
ad.attribute_name,
|
| 340 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS recorded_at,
|
| 341 |
-
COALESCE(ad.value->>'name', ad.attribute_name) AS sensor_name,
|
| 342 |
-
CASE
|
| 343 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('warn', 'mute', 'low-vol', 'fault', 'ok', 'remove', 'install', 'ok-vol-test', 'low-vol-test') THEN LOWER(ad.value->>'warn')
|
| 344 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('17', '0x17') THEN 'ok'
|
| 345 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('11', '0x11') THEN 'warn'
|
| 346 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('12', '0x12') THEN 'mute'
|
| 347 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('14', '0x14') THEN 'low-vol'
|
| 348 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('15', '0x15') THEN 'fault'
|
| 349 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('1a', '0x1a', '26') THEN 'remove'
|
| 350 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('1b', '0x1b', '27') THEN 'install'
|
| 351 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('1e', '0x1e', '30') THEN 'ok-vol-test'
|
| 352 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('1f', '0x1f', '31') THEN 'low-vol-test'
|
| 353 |
-
WHEN COALESCE(ad.value->>'warn', '') = '' THEN NULL
|
| 354 |
-
ELSE 'unknown'
|
| 355 |
-
END AS warn_type,
|
| 356 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'temp', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS temperature,
|
| 357 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'humi', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS humidity,
|
| 358 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'bat_percent', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_percent,
|
| 359 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'bat_voltage', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_voltage,
|
| 360 |
-
CASE
|
| 361 |
-
WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('warn', 'mute', '11', '0x11', '12', '0x12', '1', 'true') THEN 1
|
| 362 |
-
ELSE 0
|
| 363 |
-
END AS smoke_detected,
|
| 364 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS recorded_at,
|
| 365 |
-
ad.value AS sensor_data
|
| 366 |
-
FROM {self._get_schema()}.asset a
|
| 367 |
-
JOIN {self._get_schema()}.asset_datapoint ad ON ad.entity_id = a.id
|
| 368 |
-
WHERE {exact_match}
|
| 369 |
-
{timestamp_window_filter}
|
| 370 |
-
ORDER BY recorded_at ASC, ad.attribute_name;
|
| 371 |
-
""".strip()
|
| 372 |
-
|
| 373 |
-
def _build_group_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
|
| 374 |
-
interval = self._interval_sql(time_window)
|
| 375 |
-
root_id = self._escape_sql(asset.db_asset_id or asset.asset_id)
|
| 376 |
-
offset = self._settings.local_time_offset_hours
|
| 377 |
-
timestamp_window_filter = self._window_filter_sql("ad.timestamp", time_window, local=False)
|
| 378 |
-
sampled = self._is_multi_day_window(time_window)
|
| 379 |
-
per_day_points = self._points_per_day(time_window)
|
| 380 |
-
if sampled:
|
| 381 |
-
return f"""
|
| 382 |
-
WITH RECURSIVE hierarchy AS (
|
| 383 |
-
SELECT id, name, parent_id, attributes::jsonb AS attributes
|
| 384 |
-
FROM {self._get_schema()}.asset
|
| 385 |
-
WHERE id = '{root_id}'
|
| 386 |
-
UNION ALL
|
| 387 |
-
SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
|
| 388 |
-
FROM {self._get_schema()}.asset c
|
| 389 |
-
JOIN hierarchy h ON c.parent_id = h.id
|
| 390 |
-
),
|
| 391 |
-
devices AS (
|
| 392 |
-
SELECT id, name
|
| 393 |
-
FROM hierarchy
|
| 394 |
-
WHERE id != '{root_id}'
|
| 395 |
-
AND attributes IS NOT NULL
|
| 396 |
-
AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
|
| 397 |
-
),
|
| 398 |
-
raw_points AS (
|
| 399 |
-
SELECT
|
| 400 |
-
ad.entity_id,
|
| 401 |
-
ad.attribute_name,
|
| 402 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
|
| 403 |
-
ad.value,
|
| 404 |
-
date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
|
| 405 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 406 |
-
JOIN devices d ON d.id = ad.entity_id
|
| 407 |
-
WHERE 1=1
|
| 408 |
-
{timestamp_window_filter}
|
| 409 |
-
),
|
| 410 |
-
bucketed AS (
|
| 411 |
-
SELECT
|
| 412 |
-
rp.*,
|
| 413 |
-
ntile({per_day_points}) OVER (
|
| 414 |
-
PARTITION BY rp.entity_id, rp.day_bucket
|
| 415 |
-
ORDER BY rp.timestamp
|
| 416 |
-
) AS sample_bucket
|
| 417 |
-
FROM raw_points rp
|
| 418 |
-
),
|
| 419 |
-
sampled_points AS (
|
| 420 |
-
SELECT *
|
| 421 |
-
FROM (
|
| 422 |
-
SELECT
|
| 423 |
-
b.*,
|
| 424 |
-
row_number() OVER (
|
| 425 |
-
PARTITION BY b.entity_id, b.day_bucket, b.sample_bucket
|
| 426 |
-
ORDER BY b.timestamp DESC
|
| 427 |
-
) AS rn
|
| 428 |
-
FROM bucketed b
|
| 429 |
-
) ranked
|
| 430 |
-
WHERE rn = 1
|
| 431 |
-
)
|
| 432 |
-
SELECT
|
| 433 |
-
d.name AS asset_name,
|
| 434 |
-
sp.attribute_name,
|
| 435 |
-
sp.timestamp,
|
| 436 |
-
sp.value AS sensor_data
|
| 437 |
-
FROM devices d
|
| 438 |
-
LEFT JOIN sampled_points sp ON sp.entity_id = d.id
|
| 439 |
-
ORDER BY d.name, sp.timestamp, sp.attribute_name;
|
| 440 |
-
""".strip()
|
| 441 |
-
return f"""
|
| 442 |
-
WITH RECURSIVE hierarchy AS (
|
| 443 |
-
SELECT id, name, parent_id, attributes::jsonb AS attributes
|
| 444 |
-
FROM {self._get_schema()}.asset
|
| 445 |
-
WHERE id = '{root_id}'
|
| 446 |
-
UNION ALL
|
| 447 |
-
SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
|
| 448 |
-
FROM {self._get_schema()}.asset c
|
| 449 |
-
JOIN hierarchy h ON c.parent_id = h.id
|
| 450 |
-
),
|
| 451 |
-
devices AS (
|
| 452 |
-
SELECT id, name
|
| 453 |
-
FROM hierarchy
|
| 454 |
-
WHERE id != '{root_id}'
|
| 455 |
-
AND attributes IS NOT NULL
|
| 456 |
-
AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
|
| 457 |
-
),
|
| 458 |
-
latest_points AS (
|
| 459 |
-
SELECT DISTINCT ON (ad.entity_id, ad.attribute_name)
|
| 460 |
-
ad.entity_id,
|
| 461 |
-
ad.attribute_name,
|
| 462 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
|
| 463 |
-
ad.value
|
| 464 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 465 |
-
JOIN devices d ON d.id = ad.entity_id
|
| 466 |
-
WHERE 1=1
|
| 467 |
-
{timestamp_window_filter}
|
| 468 |
-
ORDER BY ad.entity_id, ad.attribute_name, ad.timestamp DESC
|
| 469 |
-
)
|
| 470 |
-
SELECT
|
| 471 |
-
d.name AS asset_name,
|
| 472 |
-
lp.attribute_name,
|
| 473 |
-
lp.timestamp,
|
| 474 |
-
lp.value AS sensor_data
|
| 475 |
-
FROM devices d
|
| 476 |
-
LEFT JOIN latest_points lp ON lp.entity_id = d.id
|
| 477 |
-
ORDER BY d.name, lp.attribute_name;
|
| 478 |
-
""".strip()
|
| 479 |
-
|
| 480 |
-
def _build_energy_group_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
|
| 481 |
-
root_id = self._escape_sql(asset.db_asset_id or asset.asset_id)
|
| 482 |
-
offset = self._settings.local_time_offset_hours
|
| 483 |
-
local_time_window_filter = self._window_filter_sql(
|
| 484 |
-
f"(ad.timestamp + INTERVAL '{offset} hours')",
|
| 485 |
-
time_window,
|
| 486 |
-
local=True,
|
| 487 |
-
)
|
| 488 |
-
sampled = self._is_multi_day_window(time_window)
|
| 489 |
-
per_day_points = self._points_per_day(time_window)
|
| 490 |
-
if sampled:
|
| 491 |
-
return f"""
|
| 492 |
-
WITH RECURSIVE hierarchy AS (
|
| 493 |
-
SELECT id, name, parent_id, attributes::jsonb AS attributes
|
| 494 |
-
FROM {self._get_schema()}.asset
|
| 495 |
-
WHERE id = '{root_id}'
|
| 496 |
-
UNION ALL
|
| 497 |
-
SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
|
| 498 |
-
FROM {self._get_schema()}.asset c
|
| 499 |
-
JOIN hierarchy h ON c.parent_id = h.id
|
| 500 |
-
),
|
| 501 |
-
devices AS (
|
| 502 |
-
SELECT id, name
|
| 503 |
-
FROM hierarchy
|
| 504 |
-
WHERE id != '{root_id}'
|
| 505 |
-
AND attributes IS NOT NULL
|
| 506 |
-
AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
|
| 507 |
-
),
|
| 508 |
-
raw_points AS (
|
| 509 |
-
SELECT
|
| 510 |
-
d.name AS asset_name,
|
| 511 |
-
ad.attribute_name,
|
| 512 |
-
ad.timestamp AS raw_time,
|
| 513 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS local_time,
|
| 514 |
-
ad.value AS sensor_data,
|
| 515 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentA', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_a,
|
| 516 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentB', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_b,
|
| 517 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentC', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_c,
|
| 518 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'PowerA', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS power_a,
|
| 519 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'PowerB', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS power_b,
|
| 520 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'PowerC', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS power_c,
|
| 521 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'EnergyTotalABC', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS energy_total,
|
| 522 |
-
date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
|
| 523 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 524 |
-
JOIN devices d ON d.id = ad.entity_id
|
| 525 |
-
WHERE 1=1
|
| 526 |
-
{local_time_window_filter}
|
| 527 |
-
),
|
| 528 |
-
bucketed AS (
|
| 529 |
-
SELECT
|
| 530 |
-
rp.*,
|
| 531 |
-
ntile({per_day_points}) OVER (
|
| 532 |
-
PARTITION BY rp.asset_name, rp.attribute_name, rp.day_bucket
|
| 533 |
-
ORDER BY rp.timestamp
|
| 534 |
-
) AS sample_bucket
|
| 535 |
-
FROM raw_points rp
|
| 536 |
-
),
|
| 537 |
-
sampled_points AS (
|
| 538 |
-
SELECT *
|
| 539 |
-
FROM (
|
| 540 |
-
SELECT
|
| 541 |
-
b.*,
|
| 542 |
-
row_number() OVER (
|
| 543 |
-
PARTITION BY b.asset_name, b.attribute_name, b.day_bucket, b.sample_bucket
|
| 544 |
-
ORDER BY b.local_time DESC
|
| 545 |
-
) AS rn
|
| 546 |
-
FROM bucketed b
|
| 547 |
-
) ranked
|
| 548 |
-
WHERE rn = 1
|
| 549 |
-
)
|
| 550 |
-
SELECT
|
| 551 |
-
asset_name,
|
| 552 |
-
attribute_name,
|
| 553 |
-
local_time AS recorded_at,
|
| 554 |
-
current_a,
|
| 555 |
-
current_b,
|
| 556 |
-
current_c,
|
| 557 |
-
power_a,
|
| 558 |
-
power_b,
|
| 559 |
-
power_c,
|
| 560 |
-
energy_total,
|
| 561 |
-
sensor_data
|
| 562 |
-
FROM sampled_points
|
| 563 |
-
ORDER BY asset_name, recorded_at ASC, attribute_name;
|
| 564 |
-
""".strip()
|
| 565 |
-
return f"""
|
| 566 |
-
WITH RECURSIVE hierarchy AS (
|
| 567 |
-
SELECT id, name, parent_id, attributes::jsonb AS attributes
|
| 568 |
-
FROM {self._get_schema()}.asset
|
| 569 |
-
WHERE id = '{root_id}'
|
| 570 |
-
UNION ALL
|
| 571 |
-
SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
|
| 572 |
-
FROM {self._get_schema()}.asset c
|
| 573 |
-
JOIN hierarchy h ON c.parent_id = h.id
|
| 574 |
-
),
|
| 575 |
-
devices AS (
|
| 576 |
-
SELECT id, name
|
| 577 |
-
FROM hierarchy
|
| 578 |
-
WHERE id != '{root_id}'
|
| 579 |
-
AND attributes IS NOT NULL
|
| 580 |
-
AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
|
| 581 |
-
),
|
| 582 |
-
raw_points AS (
|
| 583 |
-
SELECT
|
| 584 |
-
d.name AS asset_name,
|
| 585 |
-
ad.attribute_name,
|
| 586 |
-
ad.timestamp AS raw_time,
|
| 587 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS local_time,
|
| 588 |
-
ad.value AS sensor_data,
|
| 589 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentA', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_a,
|
| 590 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentB', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_b,
|
| 591 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentC', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_c,
|
| 592 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'PowerA', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS power_a,
|
| 593 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'PowerB', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS power_b,
|
| 594 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'PowerC', CASE WHEN ad.attribute_name ILIKE '%PowerC%' THEN ad.value::text END, ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS power_c,
|
| 595 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'EnergyTotalABC', CASE WHEN ad.attribute_name ILIKE '%Energy%' THEN ad.value::text END, ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS energy_total
|
| 596 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 597 |
-
JOIN devices d ON d.id = ad.entity_id
|
| 598 |
-
WHERE 1=1
|
| 599 |
-
{local_time_window_filter}
|
| 600 |
-
)
|
| 601 |
-
SELECT
|
| 602 |
-
asset_name,
|
| 603 |
-
attribute_name,
|
| 604 |
-
local_time AS recorded_at,
|
| 605 |
-
current_a,
|
| 606 |
-
current_b,
|
| 607 |
-
current_c,
|
| 608 |
-
power_a,
|
| 609 |
-
power_b,
|
| 610 |
-
power_c,
|
| 611 |
-
energy_total,
|
| 612 |
-
sensor_data
|
| 613 |
-
FROM raw_points
|
| 614 |
-
ORDER BY asset_name, recorded_at ASC, attribute_name;
|
| 615 |
-
""".strip()
|
| 616 |
-
|
| 617 |
-
def _build_aqi_group_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
|
| 618 |
-
root_id = self._escape_sql(asset.db_asset_id or asset.asset_id)
|
| 619 |
-
offset = self._settings.local_time_offset_hours
|
| 620 |
-
local_time_window_filter = self._window_filter_sql(
|
| 621 |
-
f"(ad.timestamp + INTERVAL '{offset} hours')",
|
| 622 |
-
time_window,
|
| 623 |
-
local=True,
|
| 624 |
-
)
|
| 625 |
-
sampled = self._is_multi_day_window(time_window)
|
| 626 |
-
per_day_points = self._points_per_day(time_window)
|
| 627 |
-
if sampled:
|
| 628 |
-
return f"""
|
| 629 |
-
WITH RECURSIVE hierarchy AS (
|
| 630 |
-
SELECT id, name, parent_id, attributes::jsonb AS attributes
|
| 631 |
-
FROM {self._get_schema()}.asset
|
| 632 |
-
WHERE id = '{root_id}'
|
| 633 |
-
UNION ALL
|
| 634 |
-
SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
|
| 635 |
-
FROM {self._get_schema()}.asset c
|
| 636 |
-
JOIN hierarchy h ON c.parent_id = h.id
|
| 637 |
-
),
|
| 638 |
-
devices AS (
|
| 639 |
-
SELECT id, name
|
| 640 |
-
FROM hierarchy
|
| 641 |
-
WHERE id != '{root_id}'
|
| 642 |
-
AND attributes IS NOT NULL
|
| 643 |
-
AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
|
| 644 |
-
),
|
| 645 |
-
raw_points AS (
|
| 646 |
-
SELECT
|
| 647 |
-
d.name AS asset_name,
|
| 648 |
-
ad.attribute_name,
|
| 649 |
-
ad.timestamp AS raw_time,
|
| 650 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS local_time,
|
| 651 |
-
ad.value AS sensor_data,
|
| 652 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'AQIIndex001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS aqi_index,
|
| 653 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'pm25', ad.value->>'PM25', ad.value->>'PM2.5', ad.value->>'pm2.5', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS pm25,
|
| 654 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'pm10', ad.value->>'PM10', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS pm10,
|
| 655 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'co2', ad.value->>'CO2', ad.value->>'AQICO2001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS co2,
|
| 656 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'voc', ad.value->>'VOC', ad.value->>'AQIVOC001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS voc,
|
| 657 |
-
date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
|
| 658 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 659 |
-
JOIN devices d ON d.id = ad.entity_id
|
| 660 |
-
WHERE 1=1
|
| 661 |
-
{local_time_window_filter}
|
| 662 |
-
),
|
| 663 |
-
bucketed AS (
|
| 664 |
-
SELECT
|
| 665 |
-
rp.*,
|
| 666 |
-
ntile({per_day_points}) OVER (
|
| 667 |
-
PARTITION BY rp.asset_name, rp.attribute_name, rp.day_bucket
|
| 668 |
-
ORDER BY rp.raw_time
|
| 669 |
-
) AS sample_bucket
|
| 670 |
-
FROM raw_points rp
|
| 671 |
-
),
|
| 672 |
-
sampled_points AS (
|
| 673 |
-
SELECT *
|
| 674 |
-
FROM (
|
| 675 |
-
SELECT
|
| 676 |
-
b.*,
|
| 677 |
-
row_number() OVER (
|
| 678 |
-
PARTITION BY b.asset_name, b.attribute_name, b.day_bucket, b.sample_bucket
|
| 679 |
-
ORDER BY b.local_time DESC
|
| 680 |
-
) AS rn
|
| 681 |
-
FROM bucketed b
|
| 682 |
-
) ranked
|
| 683 |
-
WHERE rn = 1
|
| 684 |
-
)
|
| 685 |
-
SELECT
|
| 686 |
-
asset_name,
|
| 687 |
-
attribute_name,
|
| 688 |
-
local_time AS recorded_at,
|
| 689 |
-
aqi_index,
|
| 690 |
-
pm25,
|
| 691 |
-
pm10,
|
| 692 |
-
co2,
|
| 693 |
-
voc,
|
| 694 |
-
sensor_data
|
| 695 |
-
FROM sampled_points
|
| 696 |
-
ORDER BY asset_name, recorded_at ASC, attribute_name;
|
| 697 |
-
""".strip()
|
| 698 |
-
return f"""
|
| 699 |
-
WITH RECURSIVE hierarchy AS (
|
| 700 |
-
SELECT id, name, parent_id, attributes::jsonb AS attributes
|
| 701 |
-
FROM {self._get_schema()}.asset
|
| 702 |
-
WHERE id = '{root_id}'
|
| 703 |
-
UNION ALL
|
| 704 |
-
SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
|
| 705 |
-
FROM {self._get_schema()}.asset c
|
| 706 |
-
JOIN hierarchy h ON c.parent_id = h.id
|
| 707 |
-
),
|
| 708 |
-
devices AS (
|
| 709 |
-
SELECT id, name
|
| 710 |
-
FROM hierarchy
|
| 711 |
-
WHERE id != '{root_id}'
|
| 712 |
-
AND attributes IS NOT NULL
|
| 713 |
-
AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
|
| 714 |
-
),
|
| 715 |
-
raw_points AS (
|
| 716 |
-
SELECT
|
| 717 |
-
d.name AS asset_name,
|
| 718 |
-
ad.attribute_name,
|
| 719 |
-
ad.timestamp AS raw_time,
|
| 720 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS local_time,
|
| 721 |
-
ad.value AS sensor_data,
|
| 722 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'AQIIndex001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS aqi_index,
|
| 723 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'pm25', ad.value->>'PM25', ad.value->>'PM2.5', ad.value->>'pm2.5', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS pm25,
|
| 724 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'pm10', ad.value->>'PM10', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS pm10,
|
| 725 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'co2', ad.value->>'CO2', ad.value->>'AQICO2001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS co2,
|
| 726 |
-
NULLIF(regexp_replace(COALESCE(ad.value->>'voc', ad.value->>'VOC', ad.value->>'AQIVOC001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS voc
|
| 727 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 728 |
-
JOIN devices d ON d.id = ad.entity_id
|
| 729 |
-
WHERE 1=1
|
| 730 |
-
{local_time_window_filter}
|
| 731 |
-
)
|
| 732 |
-
SELECT
|
| 733 |
-
asset_name,
|
| 734 |
-
attribute_name,
|
| 735 |
-
local_time AS recorded_at,
|
| 736 |
-
aqi_index,
|
| 737 |
-
pm25,
|
| 738 |
-
pm10,
|
| 739 |
-
co2,
|
| 740 |
-
voc,
|
| 741 |
-
sensor_data
|
| 742 |
-
FROM raw_points
|
| 743 |
-
ORDER BY asset_name, recorded_at ASC, attribute_name;
|
| 744 |
-
""".strip()
|
| 745 |
-
|
| 746 |
-
def _build_tank_group_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
|
| 747 |
-
interval = self._interval_sql(time_window)
|
| 748 |
-
root_id = self._escape_sql(asset.db_asset_id or asset.asset_id)
|
| 749 |
-
offset = self._settings.local_time_offset_hours
|
| 750 |
-
timestamp_window_filter = self._window_filter_sql("ad.timestamp", time_window, local=False)
|
| 751 |
-
sampled = self._is_multi_day_window(time_window)
|
| 752 |
-
per_day_points = self._points_per_day(time_window)
|
| 753 |
-
if sampled:
|
| 754 |
-
return f"""
|
| 755 |
-
WITH RECURSIVE hierarchy AS (
|
| 756 |
-
SELECT id, name, parent_id, attributes::jsonb AS attributes
|
| 757 |
-
FROM {self._get_schema()}.asset
|
| 758 |
-
WHERE id = '{root_id}'
|
| 759 |
-
UNION ALL
|
| 760 |
-
SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
|
| 761 |
-
FROM {self._get_schema()}.asset c
|
| 762 |
-
JOIN hierarchy h ON c.parent_id = h.id
|
| 763 |
-
),
|
| 764 |
-
devices AS (
|
| 765 |
-
SELECT id, name, attributes
|
| 766 |
-
FROM hierarchy
|
| 767 |
-
WHERE id != '{root_id}'
|
| 768 |
-
AND attributes IS NOT NULL
|
| 769 |
-
AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
|
| 770 |
-
),
|
| 771 |
-
raw_level_points AS (
|
| 772 |
-
SELECT
|
| 773 |
-
ad.entity_id,
|
| 774 |
-
ad.attribute_name,
|
| 775 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
|
| 776 |
-
ad.value,
|
| 777 |
-
date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
|
| 778 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 779 |
-
JOIN devices d ON d.id = ad.entity_id
|
| 780 |
-
WHERE 1=1
|
| 781 |
-
AND (
|
| 782 |
-
NULLIF(COALESCE(ad.value->>'TankOilLevelInFeet001', CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END), '') IS NOT NULL
|
| 783 |
-
OR NULLIF(COALESCE(ad.value->>'Level', CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END), '') IS NOT NULL
|
| 784 |
-
)
|
| 785 |
-
{timestamp_window_filter}
|
| 786 |
-
),
|
| 787 |
-
bucketed AS (
|
| 788 |
-
SELECT
|
| 789 |
-
rp.*,
|
| 790 |
-
ntile({per_day_points}) OVER (
|
| 791 |
-
PARTITION BY rp.entity_id, rp.day_bucket
|
| 792 |
-
ORDER BY rp.timestamp
|
| 793 |
-
) AS sample_bucket
|
| 794 |
-
FROM raw_level_points rp
|
| 795 |
-
),
|
| 796 |
-
tank_payload AS (
|
| 797 |
-
SELECT *
|
| 798 |
-
FROM (
|
| 799 |
-
SELECT
|
| 800 |
-
b.entity_id,
|
| 801 |
-
b.attribute_name,
|
| 802 |
-
b.timestamp,
|
| 803 |
-
b.value,
|
| 804 |
-
row_number() OVER (
|
| 805 |
-
PARTITION BY b.entity_id, b.day_bucket, b.sample_bucket
|
| 806 |
-
ORDER BY b.timestamp DESC
|
| 807 |
-
) AS rn
|
| 808 |
-
FROM bucketed b
|
| 809 |
-
) ranked
|
| 810 |
-
WHERE rn = 1
|
| 811 |
-
),
|
| 812 |
-
capacity_payload AS (
|
| 813 |
-
SELECT
|
| 814 |
-
d.id AS entity_id,
|
| 815 |
-
'asset.maxcapacity.value' AS attribute_name,
|
| 816 |
-
NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric AS max_ft_from_payload
|
| 817 |
-
FROM devices d
|
| 818 |
-
WHERE NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '') IS NOT NULL
|
| 819 |
-
)
|
| 820 |
-
SELECT
|
| 821 |
-
d.name AS asset_name,
|
| 822 |
-
tp.attribute_name AS level_attribute,
|
| 823 |
-
COALESCE(cp.attribute_name, 'asset.maxcapacity.value') AS max_ft_attribute,
|
| 824 |
-
tp.timestamp,
|
| 825 |
-
COALESCE(
|
| 826 |
-
NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric,
|
| 827 |
-
cp.max_ft_from_payload
|
| 828 |
-
) AS max_ft,
|
| 829 |
-
COALESCE(
|
| 830 |
-
NULLIF(tp.value->>'TankOilLevelInFeet001', '')::numeric,
|
| 831 |
-
NULLIF(CASE WHEN tp.attribute_name = 'TankOilLevelInFeet001' THEN tp.value::text END, '')::numeric,
|
| 832 |
-
NULLIF(tp.value->>'Level', '')::numeric,
|
| 833 |
-
NULLIF(CASE WHEN tp.attribute_name = 'Level' THEN tp.value::text END, '')::numeric
|
| 834 |
-
) AS tank_oil_level_in_feet_001,
|
| 835 |
-
GREATEST(
|
| 836 |
-
COALESCE(
|
| 837 |
-
NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric,
|
| 838 |
-
cp.max_ft_from_payload,
|
| 839 |
-
0
|
| 840 |
-
) - COALESCE(
|
| 841 |
-
NULLIF(tp.value->>'TankOilLevelInFeet001', '')::numeric,
|
| 842 |
-
NULLIF(CASE WHEN tp.attribute_name = 'TankOilLevelInFeet001' THEN tp.value::text END, '')::numeric,
|
| 843 |
-
NULLIF(tp.value->>'Level', '')::numeric,
|
| 844 |
-
NULLIF(CASE WHEN tp.attribute_name = 'Level' THEN tp.value::text END, '')::numeric,
|
| 845 |
-
0
|
| 846 |
-
),
|
| 847 |
-
0
|
| 848 |
-
) AS current_level_ft,
|
| 849 |
-
ROUND(
|
| 850 |
-
(GREATEST(COALESCE(NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric, cp.max_ft_from_payload, 0) - COALESCE(NULLIF(tp.value->>'TankOilLevelInFeet001', '')::numeric, NULLIF(tp.value->>'Level', '')::numeric, 0), 0)
|
| 851 |
-
/ NULLIF(COALESCE(NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric, cp.max_ft_from_payload), 0)) * 100,
|
| 852 |
-
1
|
| 853 |
-
) AS percentage_filled,
|
| 854 |
-
tp.value AS sensor_data
|
| 855 |
-
FROM devices d
|
| 856 |
-
LEFT JOIN tank_payload tp ON tp.entity_id = d.id
|
| 857 |
-
LEFT JOIN capacity_payload cp ON cp.entity_id = d.id
|
| 858 |
-
ORDER BY d.name, tp.timestamp;
|
| 859 |
-
""".strip()
|
| 860 |
-
return f"""
|
| 861 |
-
WITH RECURSIVE hierarchy AS (
|
| 862 |
-
SELECT id, name, parent_id, attributes::jsonb AS attributes
|
| 863 |
-
FROM {self._get_schema()}.asset
|
| 864 |
-
WHERE id = '{root_id}'
|
| 865 |
-
UNION ALL
|
| 866 |
-
SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
|
| 867 |
-
FROM {self._get_schema()}.asset c
|
| 868 |
-
JOIN hierarchy h ON c.parent_id = h.id
|
| 869 |
-
),
|
| 870 |
-
devices AS (
|
| 871 |
-
SELECT id, name, attributes
|
| 872 |
-
FROM hierarchy
|
| 873 |
-
WHERE id != '{root_id}'
|
| 874 |
-
AND attributes IS NOT NULL
|
| 875 |
-
AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
|
| 876 |
-
),
|
| 877 |
-
raw_level_points AS (
|
| 878 |
-
SELECT
|
| 879 |
-
ad.entity_id,
|
| 880 |
-
ad.attribute_name,
|
| 881 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
|
| 882 |
-
ad.value
|
| 883 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 884 |
-
JOIN devices d ON d.id = ad.entity_id
|
| 885 |
-
WHERE 1=1
|
| 886 |
-
AND (
|
| 887 |
-
NULLIF(COALESCE(ad.value->>'TankOilLevelInFeet001', CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END), '') IS NOT NULL
|
| 888 |
-
OR NULLIF(COALESCE(ad.value->>'Level', CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END), '') IS NOT NULL
|
| 889 |
-
)
|
| 890 |
-
{timestamp_window_filter}
|
| 891 |
-
ORDER BY ad.entity_id, ad.timestamp ASC, ad.attribute_name
|
| 892 |
-
),
|
| 893 |
-
tank_payload AS (
|
| 894 |
-
SELECT
|
| 895 |
-
rp.entity_id,
|
| 896 |
-
rp.attribute_name,
|
| 897 |
-
rp.timestamp,
|
| 898 |
-
rp.value
|
| 899 |
-
FROM raw_level_points rp
|
| 900 |
-
),
|
| 901 |
-
capacity_payload AS (
|
| 902 |
-
SELECT
|
| 903 |
-
d.id AS entity_id,
|
| 904 |
-
'asset.maxcapacity.value' AS attribute_name,
|
| 905 |
-
NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric AS max_ft_from_payload
|
| 906 |
-
FROM devices d
|
| 907 |
-
WHERE NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '') IS NOT NULL
|
| 908 |
-
)
|
| 909 |
-
SELECT
|
| 910 |
-
d.name AS asset_name,
|
| 911 |
-
tp.attribute_name AS level_attribute,
|
| 912 |
-
COALESCE(cp.attribute_name, 'asset.maxcapacity.value') AS max_ft_attribute,
|
| 913 |
-
tp.timestamp,
|
| 914 |
-
COALESCE(
|
| 915 |
-
NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric,
|
| 916 |
-
cp.max_ft_from_payload
|
| 917 |
-
) AS max_ft,
|
| 918 |
-
COALESCE(
|
| 919 |
-
NULLIF(tp.value->>'TankOilLevelInFeet001', '')::numeric,
|
| 920 |
-
NULLIF(CASE WHEN tp.attribute_name = 'TankOilLevelInFeet001' THEN tp.value::text END, '')::numeric,
|
| 921 |
-
NULLIF(tp.value->>'Level', '')::numeric,
|
| 922 |
-
NULLIF(CASE WHEN tp.attribute_name = 'Level' THEN tp.value::text END, '')::numeric
|
| 923 |
-
) AS tank_oil_level_in_feet_001,
|
| 924 |
-
GREATEST(
|
| 925 |
-
COALESCE(
|
| 926 |
-
NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric,
|
| 927 |
-
cp.max_ft_from_payload,
|
| 928 |
-
0
|
| 929 |
-
) - COALESCE(
|
| 930 |
-
NULLIF(tp.value->>'TankOilLevelInFeet001', '')::numeric,
|
| 931 |
-
NULLIF(CASE WHEN tp.attribute_name = 'TankOilLevelInFeet001' THEN tp.value::text END, '')::numeric,
|
| 932 |
-
NULLIF(tp.value->>'Level', '')::numeric,
|
| 933 |
-
NULLIF(CASE WHEN tp.attribute_name = 'Level' THEN tp.value::text END, '')::numeric,
|
| 934 |
-
0
|
| 935 |
-
),
|
| 936 |
-
0
|
| 937 |
-
) AS current_level_ft,
|
| 938 |
-
ROUND(
|
| 939 |
-
(GREATEST(COALESCE(NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric, cp.max_ft_from_payload, 0) - COALESCE(NULLIF(tp.value->>'TankOilLevelInFeet001', '')::numeric, NULLIF(tp.value->>'Level', '')::numeric, 0), 0)
|
| 940 |
-
/ NULLIF(COALESCE(NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric, cp.max_ft_from_payload), 0)) * 100,
|
| 941 |
-
1
|
| 942 |
-
) AS percentage_filled,
|
| 943 |
-
tp.value AS sensor_data
|
| 944 |
-
FROM devices d
|
| 945 |
-
LEFT JOIN tank_payload tp ON tp.entity_id = d.id
|
| 946 |
-
LEFT JOIN capacity_payload cp ON cp.entity_id = d.id
|
| 947 |
-
ORDER BY d.name, tp.timestamp ASC;
|
| 948 |
-
""".strip()
|
| 949 |
-
|
| 950 |
-
def _build_cold_room_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
|
| 951 |
-
temp_expr = self._coalesce_json_value(asset.temperature_keys or ["Temperature", "temperature"])
|
| 952 |
-
humidity_expr = self._coalesce_json_value(asset.humidity_keys or ["Humidity", "humidity"])
|
| 953 |
-
offset = self._settings.local_time_offset_hours
|
| 954 |
-
interval = self._interval_sql(time_window)
|
| 955 |
-
exact_match = self._exact_asset_match_sql(asset.name)
|
| 956 |
-
local_time_window_filter = self._window_filter_sql(
|
| 957 |
-
f"(ad.timestamp + INTERVAL '{offset} hours')",
|
| 958 |
-
time_window,
|
| 959 |
-
local=True,
|
| 960 |
-
)
|
| 961 |
-
sampled = self._is_multi_day_window(time_window)
|
| 962 |
-
per_day_points = self._points_per_day(time_window)
|
| 963 |
-
if sampled:
|
| 964 |
-
return f"""
|
| 965 |
-
WITH raw_data AS (
|
| 966 |
-
SELECT
|
| 967 |
-
a.id AS asset_uuid,
|
| 968 |
-
a.name AS asset_name,
|
| 969 |
-
ad.timestamp AS raw_time,
|
| 970 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS local_time,
|
| 971 |
-
regexp_replace({temp_expr}, '[^0-9\\.-]', '', 'g')::numeric AS temperature,
|
| 972 |
-
regexp_replace({humidity_expr}, '[^0-9\\.-]', '', 'g')::numeric AS humidity
|
| 973 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 974 |
-
JOIN {self._get_schema()}.asset a ON a.id = ad.entity_id
|
| 975 |
-
WHERE {exact_match}
|
| 976 |
-
AND {temp_expr} IS NOT NULL
|
| 977 |
-
AND NULLIF(regexp_replace({temp_expr}, '[^0-9\\.-]', '', 'g'), '') IS NOT NULL
|
| 978 |
-
{local_time_window_filter}
|
| 979 |
-
),
|
| 980 |
-
bucketed AS (
|
| 981 |
-
SELECT
|
| 982 |
-
rd.*,
|
| 983 |
-
date_trunc('day', rd.local_time) AS day_bucket,
|
| 984 |
-
ntile({per_day_points}) OVER (
|
| 985 |
-
PARTITION BY rd.asset_uuid, date_trunc('day', rd.local_time)
|
| 986 |
-
ORDER BY rd.local_time
|
| 987 |
-
) AS sample_bucket
|
| 988 |
-
FROM raw_data rd
|
| 989 |
-
),
|
| 990 |
-
sampled_data AS (
|
| 991 |
-
SELECT *
|
| 992 |
-
FROM (
|
| 993 |
-
SELECT
|
| 994 |
-
b.*,
|
| 995 |
-
row_number() OVER (
|
| 996 |
-
PARTITION BY b.asset_uuid, b.day_bucket, b.sample_bucket
|
| 997 |
-
ORDER BY b.local_time DESC
|
| 998 |
-
) AS rn
|
| 999 |
-
FROM bucketed b
|
| 1000 |
-
) ranked
|
| 1001 |
-
WHERE rn = 1
|
| 1002 |
-
)
|
| 1003 |
-
SELECT
|
| 1004 |
-
asset_uuid::text AS asset_id,
|
| 1005 |
-
asset_name,
|
| 1006 |
-
local_time AS recorded_at,
|
| 1007 |
-
temperature,
|
| 1008 |
-
humidity,
|
| 1009 |
-
raw_time < NOW() - INTERVAL '{asset.nominal_range.get("max_stale_hours", 2)} hours' AS is_stale
|
| 1010 |
-
FROM sampled_data
|
| 1011 |
-
ORDER BY recorded_at ASC;
|
| 1012 |
-
""".strip()
|
| 1013 |
-
|
| 1014 |
-
return f"""
|
| 1015 |
-
WITH raw_data AS (
|
| 1016 |
-
SELECT
|
| 1017 |
-
a.id AS asset_uuid,
|
| 1018 |
-
a.name AS asset_name,
|
| 1019 |
-
ad.timestamp AS raw_time,
|
| 1020 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS local_time,
|
| 1021 |
-
regexp_replace({temp_expr}, '[^0-9\\.-]', '', 'g')::numeric AS temperature,
|
| 1022 |
-
regexp_replace({humidity_expr}, '[^0-9\\.-]', '', 'g')::numeric AS humidity
|
| 1023 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 1024 |
-
JOIN {self._get_schema()}.asset a ON a.id = ad.entity_id
|
| 1025 |
-
WHERE {exact_match}
|
| 1026 |
-
AND {temp_expr} IS NOT NULL
|
| 1027 |
-
AND NULLIF(regexp_replace({temp_expr}, '[^0-9\\.-]', '', 'g'), '') IS NOT NULL
|
| 1028 |
-
{local_time_window_filter}
|
| 1029 |
-
)
|
| 1030 |
-
SELECT
|
| 1031 |
-
asset_uuid::text AS asset_id,
|
| 1032 |
-
asset_name,
|
| 1033 |
-
local_time AS recorded_at,
|
| 1034 |
-
temperature,
|
| 1035 |
-
humidity,
|
| 1036 |
-
raw_time < NOW() - INTERVAL '{asset.nominal_range.get("max_stale_hours", 2)} hours' AS is_stale
|
| 1037 |
-
FROM raw_data
|
| 1038 |
-
ORDER BY recorded_at DESC
|
| 1039 |
-
LIMIT 500;
|
| 1040 |
-
""".strip()
|
| 1041 |
-
|
| 1042 |
-
def _build_filling_machine_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
|
| 1043 |
-
offset = self._settings.local_time_offset_hours
|
| 1044 |
-
interval = self._interval_sql(time_window)
|
| 1045 |
-
exact_match = self._exact_asset_match_sql(asset.name)
|
| 1046 |
-
local_time_window_filter = self._window_filter_sql(
|
| 1047 |
-
f"(ad.timestamp + INTERVAL '{offset} hours')",
|
| 1048 |
-
time_window,
|
| 1049 |
-
local=True,
|
| 1050 |
-
)
|
| 1051 |
-
sampled = self._is_multi_day_window(time_window)
|
| 1052 |
-
per_day_points = self._points_per_day(time_window)
|
| 1053 |
-
if sampled:
|
| 1054 |
-
return f"""
|
| 1055 |
-
WITH aggregated_readings AS (
|
| 1056 |
-
SELECT
|
| 1057 |
-
a.id AS asset_uuid,
|
| 1058 |
-
a.name AS asset_name,
|
| 1059 |
-
ad.timestamp AS raw_time,
|
| 1060 |
-
jsonb_object_agg(ad.attribute_name, ad.value) AS sensor_data
|
| 1061 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 1062 |
-
JOIN {self._get_schema()}.asset a ON a.id = ad.entity_id
|
| 1063 |
-
WHERE {exact_match}
|
| 1064 |
-
{local_time_window_filter}
|
| 1065 |
-
GROUP BY a.id, a.name, ad.timestamp
|
| 1066 |
-
),
|
| 1067 |
-
raw_data AS (
|
| 1068 |
-
SELECT
|
| 1069 |
-
asset_uuid,
|
| 1070 |
-
asset_name,
|
| 1071 |
-
raw_time,
|
| 1072 |
-
(raw_time + INTERVAL '{offset} hours') AS local_time,
|
| 1073 |
-
(COALESCE(
|
| 1074 |
-
sensor_data->>'prod_shift_1_today',
|
| 1075 |
-
sensor_data->'data'->>'prod_shift_1_today'
|
| 1076 |
-
))::numeric AS shift_1_count,
|
| 1077 |
-
(COALESCE(
|
| 1078 |
-
sensor_data->>'prod_shift_2_today',
|
| 1079 |
-
sensor_data->'data'->>'prod_shift_2_today'
|
| 1080 |
-
))::numeric AS shift_2_count,
|
| 1081 |
-
(COALESCE(
|
| 1082 |
-
sensor_data->>'prod_shift_3_today',
|
| 1083 |
-
sensor_data->'data'->>'prod_shift_3_today'
|
| 1084 |
-
))::numeric AS shift_3_count,
|
| 1085 |
-
(COALESCE(
|
| 1086 |
-
sensor_data->>'prod_total_today',
|
| 1087 |
-
sensor_data->'data'->>'prod_total_today'
|
| 1088 |
-
))::numeric AS total_count,
|
| 1089 |
-
COALESCE(
|
| 1090 |
-
NULLIF(COALESCE(sensor_data->>'Timestamp', sensor_data->'data'->>'Timestamp'), '')::timestamp,
|
| 1091 |
-
raw_time + INTERVAL '{offset} hours'
|
| 1092 |
-
) AS payload_time
|
| 1093 |
-
FROM aggregated_readings
|
| 1094 |
-
WHERE COALESCE(
|
| 1095 |
-
sensor_data->>'prod_total_today',
|
| 1096 |
-
sensor_data->'data'->>'prod_total_today'
|
| 1097 |
-
) IS NOT NULL
|
| 1098 |
-
),
|
| 1099 |
-
bucketed AS (
|
| 1100 |
-
SELECT
|
| 1101 |
-
rd.*,
|
| 1102 |
-
date_trunc('day', rd.local_time) AS day_bucket,
|
| 1103 |
-
ntile({per_day_points}) OVER (
|
| 1104 |
-
PARTITION BY rd.asset_uuid, date_trunc('day', rd.local_time)
|
| 1105 |
-
ORDER BY rd.local_time
|
| 1106 |
-
) AS sample_bucket
|
| 1107 |
-
FROM raw_data rd
|
| 1108 |
-
),
|
| 1109 |
-
sampled_data AS (
|
| 1110 |
-
SELECT *
|
| 1111 |
-
FROM (
|
| 1112 |
-
SELECT
|
| 1113 |
-
b.*,
|
| 1114 |
-
row_number() OVER (
|
| 1115 |
-
PARTITION BY b.asset_uuid, b.day_bucket, b.sample_bucket
|
| 1116 |
-
ORDER BY b.local_time DESC
|
| 1117 |
-
) AS rn
|
| 1118 |
-
FROM bucketed b
|
| 1119 |
-
) ranked
|
| 1120 |
-
WHERE rn = 1
|
| 1121 |
-
)
|
| 1122 |
-
SELECT
|
| 1123 |
-
asset_uuid::text AS asset_id,
|
| 1124 |
-
asset_name,
|
| 1125 |
-
local_time AS recorded_at,
|
| 1126 |
-
shift_1_count,
|
| 1127 |
-
shift_2_count,
|
| 1128 |
-
shift_3_count,
|
| 1129 |
-
total_count,
|
| 1130 |
-
payload_time,
|
| 1131 |
-
payload_time < (NOW() + INTERVAL '{offset} hours') - INTERVAL '{asset.nominal_range.get("max_stale_hours", 6)} hours' AS is_stale
|
| 1132 |
-
FROM sampled_data
|
| 1133 |
-
ORDER BY recorded_at ASC;
|
| 1134 |
-
""".strip()
|
| 1135 |
-
|
| 1136 |
-
return f"""
|
| 1137 |
-
WITH aggregated_readings AS (
|
| 1138 |
-
SELECT
|
| 1139 |
-
a.id AS asset_uuid,
|
| 1140 |
-
a.name AS asset_name,
|
| 1141 |
-
ad.timestamp AS raw_time,
|
| 1142 |
-
jsonb_object_agg(ad.attribute_name, ad.value) AS sensor_data
|
| 1143 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 1144 |
-
JOIN {self._get_schema()}.asset a ON a.id = ad.entity_id
|
| 1145 |
-
WHERE {exact_match}
|
| 1146 |
-
{local_time_window_filter}
|
| 1147 |
-
GROUP BY a.id, a.name, ad.timestamp
|
| 1148 |
-
),
|
| 1149 |
-
raw_data AS (
|
| 1150 |
-
SELECT
|
| 1151 |
-
asset_uuid,
|
| 1152 |
-
asset_name,
|
| 1153 |
-
raw_time,
|
| 1154 |
-
(raw_time + INTERVAL '{offset} hours') AS local_time,
|
| 1155 |
-
(COALESCE(
|
| 1156 |
-
sensor_data->>'prod_shift_1_today',
|
| 1157 |
-
sensor_data->'data'->>'prod_shift_1_today'
|
| 1158 |
-
))::numeric AS shift_1_count,
|
| 1159 |
-
(COALESCE(
|
| 1160 |
-
sensor_data->>'prod_shift_2_today',
|
| 1161 |
-
sensor_data->'data'->>'prod_shift_2_today'
|
| 1162 |
-
))::numeric AS shift_2_count,
|
| 1163 |
-
(COALESCE(
|
| 1164 |
-
sensor_data->>'prod_shift_3_today',
|
| 1165 |
-
sensor_data->'data'->>'prod_shift_3_today'
|
| 1166 |
-
))::numeric AS shift_3_count,
|
| 1167 |
-
(COALESCE(
|
| 1168 |
-
sensor_data->>'prod_total_today',
|
| 1169 |
-
sensor_data->'data'->>'prod_total_today'
|
| 1170 |
-
))::numeric AS total_count,
|
| 1171 |
-
COALESCE(
|
| 1172 |
-
NULLIF(COALESCE(sensor_data->>'Timestamp', sensor_data->'data'->>'Timestamp'), '')::timestamp,
|
| 1173 |
-
raw_time + INTERVAL '{offset} hours'
|
| 1174 |
-
) AS payload_time
|
| 1175 |
-
FROM aggregated_readings
|
| 1176 |
-
WHERE COALESCE(
|
| 1177 |
-
sensor_data->>'prod_total_today',
|
| 1178 |
-
sensor_data->'data'->>'prod_total_today'
|
| 1179 |
-
) IS NOT NULL
|
| 1180 |
-
)
|
| 1181 |
-
SELECT
|
| 1182 |
-
asset_uuid::text AS asset_id,
|
| 1183 |
-
asset_name,
|
| 1184 |
-
local_time AS recorded_at,
|
| 1185 |
-
shift_1_count,
|
| 1186 |
-
shift_2_count,
|
| 1187 |
-
shift_3_count,
|
| 1188 |
-
total_count,
|
| 1189 |
-
payload_time,
|
| 1190 |
-
payload_time < (NOW() + INTERVAL '{offset} hours') - INTERVAL '{asset.nominal_range.get("max_stale_hours", 6)} hours' AS is_stale
|
| 1191 |
-
FROM raw_data
|
| 1192 |
-
ORDER BY recorded_at DESC
|
| 1193 |
-
LIMIT 500;
|
| 1194 |
-
""".strip()
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
def _build_tank_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
|
| 1198 |
-
offset = self._settings.local_time_offset_hours
|
| 1199 |
-
interval = self._interval_sql(time_window)
|
| 1200 |
-
exact_match = self._exact_asset_match_sql(asset.name)
|
| 1201 |
-
local_time_window_filter = self._window_filter_sql(
|
| 1202 |
-
f"(ad.timestamp + INTERVAL '{offset} hours')",
|
| 1203 |
-
time_window,
|
| 1204 |
-
local=True,
|
| 1205 |
-
)
|
| 1206 |
-
sampled = self._is_multi_day_window(time_window)
|
| 1207 |
-
per_day_points = self._points_per_day(time_window)
|
| 1208 |
-
if sampled:
|
| 1209 |
-
return f"""
|
| 1210 |
-
WITH tank_data AS (
|
| 1211 |
-
SELECT
|
| 1212 |
-
a.id AS asset_uuid,
|
| 1213 |
-
a.name AS asset_name,
|
| 1214 |
-
ad.attribute_name,
|
| 1215 |
-
ad.timestamp AS raw_time,
|
| 1216 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS local_time,
|
| 1217 |
-
ROUND(
|
| 1218 |
-
regexp_replace(
|
| 1219 |
-
COALESCE(
|
| 1220 |
-
ad.value->>'TankOilLevelInFeet001',
|
| 1221 |
-
CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
|
| 1222 |
-
ad.value->>'Level',
|
| 1223 |
-
CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
|
| 1224 |
-
),
|
| 1225 |
-
'[^0-9\\.-]',
|
| 1226 |
-
'',
|
| 1227 |
-
'g'
|
| 1228 |
-
)::numeric,
|
| 1229 |
-
2
|
| 1230 |
-
) AS tank_oil_level_in_feet_001
|
| 1231 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 1232 |
-
JOIN {self._get_schema()}.asset a ON a.id = ad.entity_id
|
| 1233 |
-
WHERE {exact_match}
|
| 1234 |
-
AND COALESCE(
|
| 1235 |
-
ad.value->>'TankOilLevelInFeet001',
|
| 1236 |
-
CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
|
| 1237 |
-
ad.value->>'Level',
|
| 1238 |
-
CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
|
| 1239 |
-
) IS NOT NULL
|
| 1240 |
-
AND NULLIF(
|
| 1241 |
-
regexp_replace(
|
| 1242 |
-
COALESCE(
|
| 1243 |
-
ad.value->>'TankOilLevelInFeet001',
|
| 1244 |
-
CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
|
| 1245 |
-
ad.value->>'Level',
|
| 1246 |
-
CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
|
| 1247 |
-
),
|
| 1248 |
-
'[^0-9\\.-]',
|
| 1249 |
-
'',
|
| 1250 |
-
'g'
|
| 1251 |
-
),
|
| 1252 |
-
''
|
| 1253 |
-
) IS NOT NULL
|
| 1254 |
-
{local_time_window_filter}
|
| 1255 |
-
),
|
| 1256 |
-
bucketed AS (
|
| 1257 |
-
SELECT
|
| 1258 |
-
td.*,
|
| 1259 |
-
date_trunc('day', td.local_time) AS day_bucket,
|
| 1260 |
-
ntile({per_day_points}) OVER (
|
| 1261 |
-
PARTITION BY td.asset_uuid, date_trunc('day', td.local_time)
|
| 1262 |
-
ORDER BY td.local_time
|
| 1263 |
-
) AS sample_bucket
|
| 1264 |
-
FROM tank_data td
|
| 1265 |
-
),
|
| 1266 |
-
sampled_tank_data AS (
|
| 1267 |
-
SELECT *
|
| 1268 |
-
FROM (
|
| 1269 |
-
SELECT
|
| 1270 |
-
b.*,
|
| 1271 |
-
row_number() OVER (
|
| 1272 |
-
PARTITION BY b.asset_uuid, b.day_bucket, b.sample_bucket
|
| 1273 |
-
ORDER BY b.local_time DESC
|
| 1274 |
-
) AS rn
|
| 1275 |
-
FROM bucketed b
|
| 1276 |
-
) ranked
|
| 1277 |
-
WHERE rn = 1
|
| 1278 |
-
),
|
| 1279 |
-
latest_capacity AS (
|
| 1280 |
-
SELECT
|
| 1281 |
-
a.id AS asset_uuid,
|
| 1282 |
-
'asset.maxcapacity.value' AS attribute_name,
|
| 1283 |
-
NULLIF(a.attributes::jsonb->'maxcapacity'->'value'->>'MaxFt', '')::numeric AS max_ft
|
| 1284 |
-
FROM {self._get_schema()}.asset a
|
| 1285 |
-
WHERE {exact_match}
|
| 1286 |
-
AND NULLIF(a.attributes::jsonb->'maxcapacity'->'value'->>'MaxFt', '') IS NOT NULL
|
| 1287 |
-
),
|
| 1288 |
-
ordered AS (
|
| 1289 |
-
SELECT
|
| 1290 |
-
td.asset_uuid,
|
| 1291 |
-
td.asset_name,
|
| 1292 |
-
td.attribute_name AS level_attribute,
|
| 1293 |
-
td.raw_time,
|
| 1294 |
-
td.local_time,
|
| 1295 |
-
lc.attribute_name AS max_ft_attribute,
|
| 1296 |
-
lc.max_ft,
|
| 1297 |
-
td.tank_oil_level_in_feet_001,
|
| 1298 |
-
GREATEST(COALESCE(lc.max_ft, 0) - COALESCE(td.tank_oil_level_in_feet_001, 0), 0) AS current_level_ft,
|
| 1299 |
-
LAG(GREATEST(COALESCE(lc.max_ft, 0) - COALESCE(td.tank_oil_level_in_feet_001, 0), 0))
|
| 1300 |
-
OVER (PARTITION BY asset_name ORDER BY local_time ASC) AS previous_level
|
| 1301 |
-
FROM sampled_tank_data td
|
| 1302 |
-
LEFT JOIN latest_capacity lc ON lc.asset_uuid = td.asset_uuid
|
| 1303 |
-
)
|
| 1304 |
-
SELECT
|
| 1305 |
-
asset_uuid::text AS asset_id,
|
| 1306 |
-
asset_name,
|
| 1307 |
-
level_attribute,
|
| 1308 |
-
max_ft_attribute,
|
| 1309 |
-
local_time AS recorded_at,
|
| 1310 |
-
max_ft,
|
| 1311 |
-
tank_oil_level_in_feet_001,
|
| 1312 |
-
current_level_ft,
|
| 1313 |
-
ROUND((current_level_ft / NULLIF(max_ft, 0)) * 100, 1) AS percentage_filled,
|
| 1314 |
-
CASE
|
| 1315 |
-
WHEN previous_level IS NULL THEN 0
|
| 1316 |
-
ELSE ROUND(current_level_ft - previous_level, 2)
|
| 1317 |
-
END AS level_change,
|
| 1318 |
-
raw_time < NOW() - INTERVAL '{asset.nominal_range.get("max_stale_hours", 1)} hours' AS is_stale
|
| 1319 |
-
FROM ordered
|
| 1320 |
-
ORDER BY recorded_at ASC;
|
| 1321 |
-
""".strip()
|
| 1322 |
-
|
| 1323 |
-
return f"""
|
| 1324 |
-
WITH tank_data AS (
|
| 1325 |
-
SELECT
|
| 1326 |
-
a.id AS asset_uuid,
|
| 1327 |
-
a.name AS asset_name,
|
| 1328 |
-
ad.attribute_name,
|
| 1329 |
-
ad.timestamp AS raw_time,
|
| 1330 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS local_time,
|
| 1331 |
-
ROUND(
|
| 1332 |
-
regexp_replace(
|
| 1333 |
-
COALESCE(
|
| 1334 |
-
ad.value->>'TankOilLevelInFeet001',
|
| 1335 |
-
CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
|
| 1336 |
-
ad.value->>'Level',
|
| 1337 |
-
CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
|
| 1338 |
-
),
|
| 1339 |
-
'[^0-9\\.-]',
|
| 1340 |
-
'',
|
| 1341 |
-
'g'
|
| 1342 |
-
)::numeric,
|
| 1343 |
-
2
|
| 1344 |
-
) AS tank_oil_level_in_feet_001
|
| 1345 |
-
FROM {self._get_schema()}.asset_datapoint ad
|
| 1346 |
-
JOIN {self._get_schema()}.asset a ON a.id = ad.entity_id
|
| 1347 |
-
WHERE {exact_match}
|
| 1348 |
-
AND COALESCE(
|
| 1349 |
-
ad.value->>'TankOilLevelInFeet001',
|
| 1350 |
-
CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
|
| 1351 |
-
ad.value->>'Level',
|
| 1352 |
-
CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
|
| 1353 |
-
) IS NOT NULL
|
| 1354 |
-
AND NULLIF(
|
| 1355 |
-
regexp_replace(
|
| 1356 |
-
COALESCE(
|
| 1357 |
-
ad.value->>'TankOilLevelInFeet001',
|
| 1358 |
-
CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
|
| 1359 |
-
ad.value->>'Level',
|
| 1360 |
-
CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
|
| 1361 |
-
),
|
| 1362 |
-
'[^0-9\\.-]',
|
| 1363 |
-
'',
|
| 1364 |
-
'g'
|
| 1365 |
-
),
|
| 1366 |
-
''
|
| 1367 |
-
) IS NOT NULL
|
| 1368 |
-
{local_time_window_filter}
|
| 1369 |
-
),
|
| 1370 |
-
latest_capacity AS (
|
| 1371 |
-
SELECT
|
| 1372 |
-
a.id AS asset_uuid,
|
| 1373 |
-
'asset.maxcapacity.value' AS attribute_name,
|
| 1374 |
-
NULLIF(a.attributes::jsonb->'maxcapacity'->'value'->>'MaxFt', '')::numeric AS max_ft
|
| 1375 |
-
FROM {self._get_schema()}.asset a
|
| 1376 |
-
WHERE {exact_match}
|
| 1377 |
-
AND NULLIF(a.attributes::jsonb->'maxcapacity'->'value'->>'MaxFt', '') IS NOT NULL
|
| 1378 |
-
),
|
| 1379 |
-
ordered AS (
|
| 1380 |
-
SELECT
|
| 1381 |
-
td.asset_uuid,
|
| 1382 |
-
td.asset_name,
|
| 1383 |
-
td.attribute_name AS level_attribute,
|
| 1384 |
-
td.raw_time,
|
| 1385 |
-
td.local_time,
|
| 1386 |
-
lc.attribute_name AS max_ft_attribute,
|
| 1387 |
-
lc.max_ft,
|
| 1388 |
-
td.tank_oil_level_in_feet_001,
|
| 1389 |
-
GREATEST(COALESCE(lc.max_ft, 0) - COALESCE(td.tank_oil_level_in_feet_001, 0), 0) AS current_level_ft,
|
| 1390 |
-
LAG(GREATEST(COALESCE(lc.max_ft, 0) - COALESCE(td.tank_oil_level_in_feet_001, 0), 0))
|
| 1391 |
-
OVER (PARTITION BY asset_name ORDER BY local_time ASC) AS previous_level
|
| 1392 |
-
FROM tank_data td
|
| 1393 |
-
LEFT JOIN latest_capacity lc ON lc.asset_uuid = td.asset_uuid
|
| 1394 |
-
)
|
| 1395 |
-
SELECT
|
| 1396 |
-
asset_uuid::text AS asset_id,
|
| 1397 |
-
asset_name,
|
| 1398 |
-
level_attribute,
|
| 1399 |
-
max_ft_attribute,
|
| 1400 |
-
local_time AS recorded_at,
|
| 1401 |
-
max_ft,
|
| 1402 |
-
tank_oil_level_in_feet_001,
|
| 1403 |
-
current_level_ft,
|
| 1404 |
-
ROUND((current_level_ft / NULLIF(max_ft, 0)) * 100, 1) AS percentage_filled,
|
| 1405 |
-
CASE
|
| 1406 |
-
WHEN previous_level IS NULL THEN 0
|
| 1407 |
-
ELSE ROUND(current_level_ft - previous_level, 2)
|
| 1408 |
-
END AS level_change,
|
| 1409 |
-
raw_time < NOW() - INTERVAL '{asset.nominal_range.get("max_stale_hours", 1)} hours' AS is_stale
|
| 1410 |
-
FROM ordered
|
| 1411 |
-
ORDER BY recorded_at DESC
|
| 1412 |
-
LIMIT 500;
|
| 1413 |
-
""".strip()
|
| 1414 |
-
|
| 1415 |
-
def _build_generic_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
|
| 1416 |
-
interval = self._interval_sql(time_window)
|
| 1417 |
-
exact_match = self._exact_asset_match_sql(asset.name)
|
| 1418 |
-
offset = self._settings.local_time_offset_hours
|
| 1419 |
-
timestamp_window_filter = self._window_filter_sql("ad.timestamp", time_window, local=False)
|
| 1420 |
-
sampled = self._is_multi_day_window(time_window)
|
| 1421 |
-
per_day_points = self._points_per_day(time_window)
|
| 1422 |
-
if sampled:
|
| 1423 |
-
return f"""
|
| 1424 |
-
WITH raw_data AS (
|
| 1425 |
-
SELECT
|
| 1426 |
-
a.id::text AS asset_id,
|
| 1427 |
-
a.name AS asset_name,
|
| 1428 |
-
ad.attribute_name,
|
| 1429 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
|
| 1430 |
-
ad.value AS sensor_data,
|
| 1431 |
-
date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
|
| 1432 |
-
FROM {self._get_schema()}.asset a
|
| 1433 |
-
JOIN {self._get_schema()}.asset_datapoint ad ON ad.entity_id = a.id
|
| 1434 |
-
WHERE {exact_match}
|
| 1435 |
-
{timestamp_window_filter}
|
| 1436 |
-
),
|
| 1437 |
-
bucketed AS (
|
| 1438 |
-
SELECT
|
| 1439 |
-
rd.*,
|
| 1440 |
-
ntile({per_day_points}) OVER (
|
| 1441 |
-
PARTITION BY rd.asset_id, rd.attribute_name, rd.day_bucket
|
| 1442 |
-
ORDER BY rd.timestamp
|
| 1443 |
-
) AS sample_bucket
|
| 1444 |
-
FROM raw_data rd
|
| 1445 |
-
),
|
| 1446 |
-
sampled_data AS (
|
| 1447 |
-
SELECT *
|
| 1448 |
-
FROM (
|
| 1449 |
-
SELECT
|
| 1450 |
-
b.*,
|
| 1451 |
-
row_number() OVER (
|
| 1452 |
-
PARTITION BY b.asset_id, b.attribute_name, b.day_bucket, b.sample_bucket
|
| 1453 |
-
ORDER BY b.timestamp DESC
|
| 1454 |
-
) AS rn
|
| 1455 |
-
FROM bucketed b
|
| 1456 |
-
) ranked
|
| 1457 |
-
WHERE rn = 1
|
| 1458 |
-
)
|
| 1459 |
-
SELECT
|
| 1460 |
-
asset_id,
|
| 1461 |
-
asset_name,
|
| 1462 |
-
attribute_name,
|
| 1463 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
|
| 1464 |
-
sensor_data
|
| 1465 |
-
FROM sampled_data
|
| 1466 |
-
ORDER BY timestamp ASC, attribute_name;
|
| 1467 |
-
""".strip()
|
| 1468 |
-
return f"""
|
| 1469 |
-
SELECT
|
| 1470 |
-
a.id::text AS asset_id,
|
| 1471 |
-
a.name AS asset_name,
|
| 1472 |
-
ad.attribute_name,
|
| 1473 |
-
(ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
|
| 1474 |
-
ad.value AS sensor_data
|
| 1475 |
-
FROM {self._get_schema()}.asset a
|
| 1476 |
-
JOIN {self._get_schema()}.asset_datapoint ad ON ad.entity_id = a.id
|
| 1477 |
-
WHERE {exact_match}
|
| 1478 |
-
{timestamp_window_filter}
|
| 1479 |
-
ORDER BY ad.timestamp DESC
|
| 1480 |
-
LIMIT 500;
|
| 1481 |
-
""".strip()
|
| 1482 |
-
|
| 1483 |
-
def _coalesce_json_value(self, keys: list[str]) -> str:
|
| 1484 |
-
expressions = []
|
| 1485 |
-
for key in keys:
|
| 1486 |
-
expressions.append(f"ad.value->>'{key}'")
|
| 1487 |
-
expressions.append(f"CASE WHEN ad.attribute_name = '{key}' THEN ad.value::text END")
|
| 1488 |
-
return f"COALESCE({', '.join(expressions)})"
|
| 1489 |
-
|
| 1490 |
-
def _exact_asset_match_sql(self, name: str) -> str:
|
| 1491 |
-
escaped = self._escape_sql(name)
|
| 1492 |
-
return (
|
| 1493 |
-
"REPLACE(LOWER(a.name), ' ', '') = "
|
| 1494 |
-
f"REPLACE(LOWER('{escaped}'), ' ', '')"
|
| 1495 |
-
)
|
| 1496 |
-
|
| 1497 |
-
def _interval_sql(self, time_window: TimeWindow) -> str:
|
| 1498 |
-
unit = time_window.unit.rstrip("s")
|
| 1499 |
-
value = max(1, time_window.value)
|
| 1500 |
-
return f"{value} {unit}"
|
| 1501 |
-
|
| 1502 |
-
def _window_filter_sql(self, column_expr: str, time_window: TimeWindow, local: bool) -> str:
|
| 1503 |
-
bounds = self._absolute_window_bounds(time_window)
|
| 1504 |
-
offset = self._settings.local_time_offset_hours
|
| 1505 |
-
if bounds is not None:
|
| 1506 |
-
start_dt, end_dt = bounds
|
| 1507 |
-
start_text = start_dt.strftime("%Y-%m-%d %H:%M:%S")
|
| 1508 |
-
end_text = end_dt.strftime("%Y-%m-%d %H:%M:%S")
|
| 1509 |
-
if not local:
|
| 1510 |
-
return (
|
| 1511 |
-
f"AND {column_expr} >= TIMESTAMP '{start_text}' - INTERVAL '{offset} hours'\n"
|
| 1512 |
-
f" AND {column_expr} < TIMESTAMP '{end_text}' - INTERVAL '{offset} hours'"
|
| 1513 |
-
)
|
| 1514 |
-
return (
|
| 1515 |
-
f"AND {column_expr} >= TIMESTAMP '{start_text}'\n"
|
| 1516 |
-
f" AND {column_expr} < TIMESTAMP '{end_text}'"
|
| 1517 |
-
)
|
| 1518 |
-
|
| 1519 |
-
interval = self._interval_sql(time_window)
|
| 1520 |
-
if local:
|
| 1521 |
-
return (
|
| 1522 |
-
f"AND {column_expr} >= NOW() + INTERVAL '{offset} hours' - INTERVAL '{interval}'"
|
| 1523 |
-
)
|
| 1524 |
-
return f"AND {column_expr} >= NOW() - INTERVAL '{interval}'"
|
| 1525 |
-
|
| 1526 |
-
def _absolute_window_bounds(self, time_window: TimeWindow) -> tuple[datetime, datetime] | None:
|
| 1527 |
-
if not time_window.start_at or not time_window.end_at:
|
| 1528 |
-
return None
|
| 1529 |
-
try:
|
| 1530 |
-
start_dt = datetime.fromisoformat(time_window.start_at)
|
| 1531 |
-
end_dt = datetime.fromisoformat(time_window.end_at)
|
| 1532 |
-
return start_dt, end_dt
|
| 1533 |
-
except ValueError:
|
| 1534 |
-
return None
|
| 1535 |
-
|
| 1536 |
-
def _is_multi_day_window(self, time_window: TimeWindow) -> bool:
|
| 1537 |
-
if time_window.unit in {"days", "weeks", "months"}:
|
| 1538 |
-
return True
|
| 1539 |
-
return time_window.unit == "hours" and time_window.value >= 24
|
| 1540 |
-
|
| 1541 |
-
def _points_per_day(self, time_window: TimeWindow) -> int:
|
| 1542 |
-
# Keep payload manageable while preserving trend shape for long windows.
|
| 1543 |
-
if time_window.unit == "months":
|
| 1544 |
-
return 10
|
| 1545 |
-
if time_window.unit in {"weeks", "days"}:
|
| 1546 |
-
return 12
|
| 1547 |
-
return 15
|
| 1548 |
-
|
| 1549 |
-
def _escape_sql(self, value: str) -> str:
|
| 1550 |
-
return value.replace("'", "''")
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
nl_to_sql_service = NLToSQLService()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/analysis/backend/app/services/report_builder.py
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
|
| 5 |
-
from jinja2 import Template
|
| 6 |
-
|
| 7 |
-
from agents.analysis.backend.app.models.schemas import AnalysisPlan, AnalysisReport, AssetProfile
|
| 8 |
-
from agents.analysis.backend.app.services.deterministic_analysis import deterministic_analysis_service
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class ReportBuilderService:
|
| 12 |
-
def __init__(self) -> None:
|
| 13 |
-
self._template_path = Path(__file__).resolve().parents[1] / "templates" / "report.html"
|
| 14 |
-
|
| 15 |
-
def build(
|
| 16 |
-
self,
|
| 17 |
-
asset: AssetProfile,
|
| 18 |
-
plan: AnalysisPlan,
|
| 19 |
-
rows: list[dict],
|
| 20 |
-
summary_override: str = "",
|
| 21 |
-
) -> AnalysisReport:
|
| 22 |
-
title = f"{asset.name} - {plan.analysis_name}"
|
| 23 |
-
summary_body = summary_override or self._summarize_rows(asset, plan, rows)
|
| 24 |
-
summary = self._with_analysis_period(plan, summary_body)
|
| 25 |
-
markdown = self._to_markdown(title=title, asset=asset, plan=plan, rows=rows, summary=summary)
|
| 26 |
-
html = self._to_html(title=title, asset=asset, plan=plan, rows=rows, summary=summary)
|
| 27 |
-
return AnalysisReport(
|
| 28 |
-
title=title,
|
| 29 |
-
summary=summary,
|
| 30 |
-
markdown=markdown,
|
| 31 |
-
html=html,
|
| 32 |
-
)
|
| 33 |
-
|
| 34 |
-
def _summarize_rows(self, asset: AssetProfile, plan: AnalysisPlan, rows: list[dict]) -> str:
|
| 35 |
-
if not rows:
|
| 36 |
-
return (
|
| 37 |
-
f"No rows were returned for {asset.name}. "
|
| 38 |
-
f"The agent selected `{plan.analysis_name}` for {plan.time_window.label}."
|
| 39 |
-
)
|
| 40 |
-
|
| 41 |
-
metrics = ", ".join(asset.metrics) or "available telemetry"
|
| 42 |
-
stats = deterministic_analysis_service.summarize(asset, rows)
|
| 43 |
-
metric_lines = []
|
| 44 |
-
for metric, values in list(stats["numeric_metrics"].items())[:3]:
|
| 45 |
-
metric_lines.append(
|
| 46 |
-
f"{metric}: latest {values['latest']:.2f}, min {values['min']:.2f}, "
|
| 47 |
-
f"max {values['max']:.2f}, avg {values['avg']:.2f}"
|
| 48 |
-
)
|
| 49 |
-
findings = " ".join(stats["findings"][:3])
|
| 50 |
-
stats_text = " ".join(metric_lines)
|
| 51 |
-
return (
|
| 52 |
-
f"{asset.name} was analyzed using `{plan.analysis_name}` over {plan.time_window.label}. "
|
| 53 |
-
f"The report uses {len(rows)} rows and focuses on {metrics}. {findings} {stats_text}".strip()
|
| 54 |
-
)
|
| 55 |
-
|
| 56 |
-
def _with_analysis_period(self, plan: AnalysisPlan, summary: str) -> str:
|
| 57 |
-
period = plan.query_window_label or plan.time_window.label
|
| 58 |
-
prefix = f"Analysis period: {period}."
|
| 59 |
-
cleaned_summary = summary.strip()
|
| 60 |
-
if cleaned_summary.lower().startswith("analysis period:"):
|
| 61 |
-
return cleaned_summary
|
| 62 |
-
if not cleaned_summary:
|
| 63 |
-
return prefix
|
| 64 |
-
return f"{prefix}\n\n{cleaned_summary}"
|
| 65 |
-
|
| 66 |
-
def _to_markdown(
|
| 67 |
-
self,
|
| 68 |
-
title: str,
|
| 69 |
-
asset: AssetProfile,
|
| 70 |
-
plan: AnalysisPlan,
|
| 71 |
-
rows: list[dict],
|
| 72 |
-
summary: str,
|
| 73 |
-
) -> str:
|
| 74 |
-
sample = rows[:5]
|
| 75 |
-
lines = [
|
| 76 |
-
f"# {title}",
|
| 77 |
-
"",
|
| 78 |
-
f"**Asset:** {asset.name}",
|
| 79 |
-
f"**Asset type:** {asset.asset_type}",
|
| 80 |
-
f"**Analysis:** {plan.analysis_name}",
|
| 81 |
-
f"**Time window:** {plan.time_window.label}",
|
| 82 |
-
"",
|
| 83 |
-
"## Summary",
|
| 84 |
-
summary,
|
| 85 |
-
"",
|
| 86 |
-
"## Reasoning",
|
| 87 |
-
plan.reasoning,
|
| 88 |
-
"",
|
| 89 |
-
"## SQL",
|
| 90 |
-
"```sql",
|
| 91 |
-
plan.sql_query,
|
| 92 |
-
"```",
|
| 93 |
-
"",
|
| 94 |
-
"## Sample Rows",
|
| 95 |
-
"```json",
|
| 96 |
-
str(sample),
|
| 97 |
-
"```",
|
| 98 |
-
]
|
| 99 |
-
return "\n".join(lines)
|
| 100 |
-
|
| 101 |
-
def _to_html(
|
| 102 |
-
self,
|
| 103 |
-
title: str,
|
| 104 |
-
asset: AssetProfile,
|
| 105 |
-
plan: AnalysisPlan,
|
| 106 |
-
rows: list[dict],
|
| 107 |
-
summary: str,
|
| 108 |
-
) -> str:
|
| 109 |
-
template = Template(self._template_path.read_text(encoding="utf-8"))
|
| 110 |
-
columns = list(rows[0].keys()) if rows else ["message"]
|
| 111 |
-
display_rows = rows[:10] if rows else [{"message": "No rows returned"}]
|
| 112 |
-
return template.render(
|
| 113 |
-
title=title,
|
| 114 |
-
subtitle=f"{asset.asset_type} | {plan.time_window.label}",
|
| 115 |
-
summary=summary,
|
| 116 |
-
details=plan.reasoning,
|
| 117 |
-
sql_query=plan.sql_query,
|
| 118 |
-
columns=columns,
|
| 119 |
-
rows=display_rows,
|
| 120 |
-
)
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
report_builder_service = ReportBuilderService()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|