zaheer140 commited on
Commit
5cc22d3
·
verified ·
1 Parent(s): 52a9d98

Delete agents

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. agents/analysis/.DS_Store +0 -0
  2. agents/analysis/.env.example +0 -7
  3. agents/analysis/.gitignore +0 -9
  4. agents/analysis/backend/app/__init__.py +0 -1
  5. agents/analysis/backend/app/__pycache__/__init__.cpython-313.pyc +0 -0
  6. agents/analysis/backend/app/__pycache__/main.cpython-313.pyc +0 -0
  7. agents/analysis/backend/app/api/routes/__init__.py +0 -1
  8. agents/analysis/backend/app/api/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  9. agents/analysis/backend/app/api/routes/__pycache__/analysis.cpython-313.pyc +0 -0
  10. agents/analysis/backend/app/api/routes/__pycache__/assets.cpython-313.pyc +0 -0
  11. agents/analysis/backend/app/api/routes/__pycache__/chat.cpython-313.pyc +0 -0
  12. agents/analysis/backend/app/api/routes/__pycache__/schedules.cpython-313.pyc +0 -0
  13. agents/analysis/backend/app/api/routes/analysis.py +0 -12
  14. agents/analysis/backend/app/api/routes/assets.py +0 -19
  15. agents/analysis/backend/app/api/routes/chat.py +0 -12
  16. agents/analysis/backend/app/api/routes/schedules.py +0 -42
  17. agents/analysis/backend/app/core/__pycache__/config.cpython-313.pyc +0 -0
  18. agents/analysis/backend/app/core/config.py +0 -28
  19. agents/analysis/backend/app/main.py +0 -33
  20. agents/analysis/backend/app/models/__init__.py +0 -1
  21. agents/analysis/backend/app/models/__pycache__/__init__.cpython-313.pyc +0 -0
  22. agents/analysis/backend/app/models/__pycache__/schemas.cpython-313.pyc +0 -0
  23. agents/analysis/backend/app/models/schemas.py +0 -123
  24. agents/analysis/backend/app/prompts/analysis_planner.txt +0 -24
  25. agents/analysis/backend/app/prompts/request_planner.txt +0 -37
  26. agents/analysis/backend/app/prompts/result_analyst.txt +0 -34
  27. agents/analysis/backend/app/prompts/sql_generator.txt +0 -46
  28. agents/analysis/backend/app/services/__init__.py +0 -1
  29. agents/analysis/backend/app/services/__pycache__/__init__.cpython-313.pyc +0 -0
  30. agents/analysis/backend/app/services/__pycache__/analysis_agent.cpython-313.pyc +0 -0
  31. agents/analysis/backend/app/services/__pycache__/asset_analysis_instructions.cpython-313.pyc +0 -0
  32. agents/analysis/backend/app/services/__pycache__/asset_catalog.cpython-313.pyc +0 -0
  33. agents/analysis/backend/app/services/__pycache__/data_repository.cpython-313.pyc +0 -0
  34. agents/analysis/backend/app/services/__pycache__/deterministic_analysis.cpython-313.pyc +0 -0
  35. agents/analysis/backend/app/services/__pycache__/live_asset_registry.cpython-313.pyc +0 -0
  36. agents/analysis/backend/app/services/__pycache__/llm_service.cpython-313.pyc +0 -0
  37. agents/analysis/backend/app/services/__pycache__/nl_to_sql.cpython-313.pyc +0 -0
  38. agents/analysis/backend/app/services/__pycache__/report_builder.cpython-313.pyc +0 -0
  39. agents/analysis/backend/app/services/__pycache__/scheduling_agent.cpython-313.pyc +0 -0
  40. agents/analysis/backend/app/services/__pycache__/sql_guard.cpython-313.pyc +0 -0
  41. agents/analysis/backend/app/services/__pycache__/trend_chart_service.cpython-313.pyc +0 -0
  42. agents/analysis/backend/app/services/analysis_agent.py +0 -882
  43. agents/analysis/backend/app/services/asset_analysis_instructions.py +0 -178
  44. agents/analysis/backend/app/services/asset_catalog.py +0 -65
  45. agents/analysis/backend/app/services/data_repository.py +0 -49
  46. agents/analysis/backend/app/services/deterministic_analysis.py +0 -638
  47. agents/analysis/backend/app/services/live_asset_registry.py +0 -469
  48. agents/analysis/backend/app/services/llm_service.py +0 -310
  49. agents/analysis/backend/app/services/nl_to_sql.py +0 -1553
  50. 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()