zaheer140 commited on
Commit
7a65d9f
·
verified ·
1 Parent(s): c0b6351

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 -119
  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 -876
  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 -1542
  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.19 kB)
 
agents/analysis/backend/app/models/schemas.py DELETED
@@ -1,119 +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
-
80
-
81
- class TrendPoint(BaseModel):
82
- label: str
83
- value: float
84
-
85
-
86
- class TrendSeries(BaseModel):
87
- key: str
88
- label: str
89
- color: str
90
- points: list[TrendPoint] = Field(default_factory=list)
91
-
92
-
93
- class TrendChart(BaseModel):
94
- title: str
95
- chart_type: Literal["line", "bar"]
96
- x_label: str
97
- y_label: str
98
- summary: str = ""
99
- series: list[TrendSeries] = Field(default_factory=list)
100
-
101
-
102
- class AnalysisResponse(BaseModel):
103
- asset: AssetProfile
104
- plan: AnalysisPlan
105
- report: AnalysisReport
106
- trend_chart: TrendChart | None = None
107
- trend_charts: list[TrendChart] = Field(default_factory=list)
108
- rows: list[dict] = Field(default_factory=list)
109
-
110
-
111
- class ChatRequest(BaseModel):
112
- message: str
113
- user_id: str = "user_1"
114
- asset_id: str | None = None
115
-
116
-
117
- class ChatResponse(BaseModel):
118
- answer: str
119
- 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.2 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 (56.9 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,876 +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
- )
42
- analysis = await self.run_analysis(analysis_request, user_id=payload.user_id)
43
- return ChatResponse(answer=analysis.report.summary, analysis=analysis)
44
-
45
- async def run_analysis(self, payload: AnalysisRequest, user_id: str = "user_1") -> AnalysisResponse:
46
- candidates = live_asset_registry.find_candidates(payload.question, user_id=user_id)
47
- direct_match = live_asset_registry.best_direct_match(payload.question, user_id=user_id)
48
- planner_result = self._default_planner_result()
49
- if direct_match is not None:
50
- planner_result["asset_id"] = direct_match.asset_id
51
- planner_result["reasoning"] = "Direct asset match found from the user message."
52
- elif payload.asset_id:
53
- planner_result["asset_id"] = payload.asset_id
54
- planner_result["reasoning"] = "Asset id was provided directly in request."
55
- else:
56
- planner_result = llm_service.plan_request(
57
- user_question=payload.question,
58
- asset_catalog_context=self._build_asset_catalog_context(candidates),
59
- )
60
-
61
- asset = self._resolve_asset(payload.asset_id, planner_result, payload.question, candidates, user_id=user_id)
62
-
63
- # Guardrail: If user asked for 'coldroom' but we picked a 'tank', check if we have better candidates
64
- if asset.asset_type == "tank" and "cold" in payload.question.lower() and "room" in payload.question.lower():
65
- for c in candidates:
66
- if c.asset_type == "cold_room":
67
- asset = c
68
- planner_result["reasoning"] = "User asked for coldrooms, overriding tank selection with a valid coldroom candidate."
69
- break
70
-
71
- analysis_name = payload.analysis_name or planner_result.get("analysis_name") or "historical_variance"
72
- time_window = payload.time_window or self._parse_time_window(planner_result.get("time_window"))
73
- explicit_time_window = self._extract_explicit_time_range_window(payload.question)
74
- if explicit_time_window is not None:
75
- started_at, ended_at, label = explicit_time_window
76
- duration_hours = max(
77
- 1,
78
- int(math.ceil((ended_at - started_at).total_seconds() / 3600)),
79
- )
80
- time_window = TimeWindow(
81
- scope="historical",
82
- value=duration_hours,
83
- unit="hours",
84
- label=label,
85
- start_at=started_at.isoformat(),
86
- end_at=ended_at.isoformat(),
87
- )
88
- elif (relative_day_window := self._extract_relative_day_window(payload.question)) is not None:
89
- started_at, ended_at, label = relative_day_window
90
- duration_hours = max(
91
- 1,
92
- int(math.ceil((ended_at - started_at).total_seconds() / 3600)),
93
- )
94
- time_window = TimeWindow(
95
- scope="historical",
96
- value=duration_hours,
97
- unit="hours",
98
- label=label,
99
- start_at=started_at.isoformat(),
100
- end_at=ended_at.isoformat(),
101
- )
102
- elif (relative_duration_window := self._extract_relative_duration_window(payload.question)) is not None:
103
- time_window = relative_duration_window
104
- if explicit_time_window is None and (specific_date_window := self._extract_specific_date_window(payload.question)) is not None:
105
- started_at, ended_at, label = specific_date_window
106
- time_window = TimeWindow(
107
- scope="historical",
108
- value=1,
109
- unit="days",
110
- label=label,
111
- start_at=started_at.isoformat(),
112
- end_at=ended_at.isoformat(),
113
- )
114
- if payload.time_window is None and not self._question_mentions_time_window(payload.question):
115
- time_window = self._default_analysis_time_window()
116
- asset_context = build_asset_llm_context(asset)
117
- query_window = self._build_query_window(time_window)
118
-
119
- use_deterministic_sql = (
120
- asset.asset_type in {"tank", "cold_room", "smoke_alarm"}
121
- or (not asset.is_device and asset.child_count > 0)
122
- )
123
- sql_result = {"sql_query": "", "total_tokens": 0, "raw_response": ""}
124
- if use_deterministic_sql:
125
- sql_query = nl_to_sql_service.build_query(
126
- asset=asset,
127
- question=payload.question,
128
- analysis_name=analysis_name,
129
- time_window=time_window,
130
- ).query
131
- else:
132
- sql_result = llm_service.generate_sql(
133
- user_question=payload.question,
134
- asset_context=asset_context,
135
- analysis_name=analysis_name,
136
- time_window_label=time_window.label,
137
- )
138
- sql_query = nl_to_sql_service.normalize_generated_query(sql_result["sql_query"], asset)
139
-
140
- warnings: list[str] = []
141
- try:
142
- validate_read_only_sql(sql_query)
143
- except Exception:
144
- fallback_sql = nl_to_sql_service.build_query(
145
- asset=asset,
146
- question=payload.question,
147
- analysis_name=analysis_name,
148
- time_window=time_window,
149
- )
150
- sql_query = fallback_sql.query
151
- warnings.append("The LLM SQL was invalid, so a safe fallback SQL template was used.")
152
-
153
- try:
154
- rows = data_repository.execute_query(sql_query)
155
- except DataQueryError as exc:
156
- fallback_sql = nl_to_sql_service.build_query(
157
- asset=asset,
158
- question=payload.question,
159
- analysis_name=analysis_name,
160
- time_window=time_window,
161
- )
162
- sql_query = fallback_sql.query
163
- warnings.append("The generated SQL failed at runtime, so a safe fallback query was used.")
164
- try:
165
- rows = data_repository.execute_query(sql_query)
166
- except DataQueryError as retry_exc:
167
- raise HTTPException(status_code=502, detail=str(retry_exc)) from retry_exc
168
- rows = self._clip_rows_to_window(rows, time_window)
169
- # Flatten rows to ensure nested sensor_data is usable by charting and deterministic tools
170
- flattened_rows = []
171
- for row in rows:
172
- new_row = dict(row)
173
- sd = row.get("sensor_data")
174
- if isinstance(sd, dict):
175
- for k, v in sd.items():
176
- if k not in new_row:
177
- new_row[k] = v
178
- flattened_rows.append(new_row)
179
- rows = flattened_rows
180
-
181
- latest_row_time = self._latest_row_timestamp(rows)
182
- query_window = self._apply_display_window_override(payload.question, time_window, query_window)
183
-
184
- deterministic_summary = deterministic_analysis_service.summarize(asset, rows)
185
- warnings.extend(list(deterministic_summary["warnings"]))
186
- if latest_row_time is not None:
187
- try:
188
- request_end = datetime.fromisoformat(query_window["ended_at_iso"])
189
- if latest_row_time.tzinfo is None and request_end.tzinfo is not None:
190
- latest_row_time = latest_row_time.replace(tzinfo=request_end.tzinfo)
191
- if latest_row_time < request_end:
192
- diff = request_end - latest_row_time
193
- if diff.total_seconds() > 3600:
194
- hours = int(diff.total_seconds() // 3600)
195
- warnings.append(
196
- f"The data for {asset.name} appears to be stale ({hours} hours old). "
197
- f"Latest telemetry available is from {latest_row_time.strftime('%d %b %Y, %I:%M %p')} (Asia/Karachi)."
198
- )
199
- else:
200
- warnings.append(
201
- f"Latest telemetry available for {asset.name} in this window is {latest_row_time.strftime('%d %b %Y, %I:%M %p')} (Asia/Karachi)."
202
- )
203
- except Exception:
204
- pass
205
-
206
- if asset.asset_type == "smoke_alarm":
207
- final_analysis = {
208
- "answer": self._build_smoke_alarm_answer(asset, time_window, deterministic_summary, rows),
209
- "total_tokens": 0,
210
- "raw_response": "",
211
- "prompt_text": "",
212
- }
213
- else:
214
- final_analysis = llm_service.analyze_result(
215
- user_question=payload.question,
216
- asset_context=asset_context,
217
- sql_query=sql_query,
218
- deterministic_findings=deterministic_summary,
219
- rows=rows,
220
- )
221
-
222
- total_tokens = (
223
- int(sql_result.get("total_tokens", 0))
224
- + int(final_analysis.get("total_tokens", 0))
225
- )
226
-
227
- plan = AnalysisPlan(
228
- analysis_name=analysis_name,
229
- reasoning=planner_result.get("reasoning", ""),
230
- time_window=time_window,
231
- query_started_at=query_window["started_at_iso"],
232
- query_ended_at=query_window["ended_at_iso"],
233
- query_window_label=query_window["label"],
234
- planner_prompt=planner_result.get("prompt_text", ""),
235
- sql_prompt=sql_result.get("prompt_text", ""),
236
- analyst_prompt=final_analysis.get("prompt_text", ""),
237
- sql_query=sql_query,
238
- chart_hints=self._chart_hints_for_analysis(analysis_name),
239
- warnings=warnings,
240
- total_tokens=total_tokens,
241
- llm_raw_response=final_analysis.get("raw_response", ""),
242
- )
243
- report = report_builder_service.build(
244
- asset=asset,
245
- plan=plan,
246
- rows=rows,
247
- summary_override=final_analysis.get("answer", ""),
248
- )
249
- trend_chart = trend_chart_service.build(asset, analysis_name, time_window, rows)
250
- trend_charts = [trend_chart] if trend_chart else []
251
- return AnalysisResponse(
252
- asset=asset,
253
- plan=plan,
254
- report=report,
255
- trend_chart=trend_chart,
256
- trend_charts=trend_charts,
257
- rows=rows,
258
- )
259
-
260
- def _build_smoke_alarm_answer(
261
- self,
262
- asset,
263
- time_window: TimeWindow,
264
- deterministic_summary: dict[str, object],
265
- rows: list[dict],
266
- ) -> str:
267
- findings = [str(item).strip() for item in deterministic_summary.get("findings", []) if str(item).strip()]
268
- warnings = [str(item).strip() for item in deterministic_summary.get("warnings", []) if str(item).strip()]
269
-
270
- if not rows:
271
- lines = [
272
- f"No smoke-alarm telemetry rows were found for {asset.name} in {time_window.label}.",
273
- ]
274
- if warnings:
275
- lines.append("")
276
- lines.extend(f"- {item}" for item in warnings[:4])
277
- return "\n".join(lines)
278
-
279
- headline = findings[0] if findings else f"{asset.name} smoke-alarm telemetry was analyzed for {time_window.label}."
280
- latest_states = [item for item in findings[1:] if ": " in item][:6]
281
- other_findings = [item for item in findings[1:] if item not in latest_states][:4]
282
-
283
- lines = [headline]
284
-
285
- if other_findings:
286
- lines.append("")
287
- lines.append("Key points")
288
- lines.extend(f"- {item}" for item in other_findings)
289
-
290
- if latest_states:
291
- lines.append("")
292
- lines.append("Latest device states")
293
- lines.extend(f"- {item}" for item in latest_states)
294
-
295
- if warnings:
296
- lines.append("")
297
- lines.append("Attention items")
298
- lines.extend(f"- {item}" for item in warnings[:4])
299
-
300
- return "\n".join(lines)
301
-
302
- def _apply_display_window_override(
303
- self,
304
- question: str,
305
- time_window: TimeWindow,
306
- query_window: dict[str, str],
307
- ) -> dict[str, str]:
308
- text = question.lower()
309
- # Keep window as-is unless user asked for "today".
310
- if "today" not in text:
311
- return query_window
312
- if not time_window.start_at:
313
- return query_window
314
- start_at = self._parse_datetime(time_window.start_at)
315
- if start_at is None:
316
- return query_window
317
- end_at = self._parse_datetime(query_window.get("ended_at_iso", ""))
318
- if end_at is None:
319
- return query_window
320
-
321
- friendly_format = "%d %b %Y, %I:%M %p"
322
- query_window["label"] = (
323
- f"{start_at.strftime(friendly_format)} to "
324
- f"{end_at.strftime(friendly_format)} "
325
- f"(Asia/Karachi)"
326
- )
327
- return query_window
328
-
329
- def _latest_row_timestamp(self, rows: list[dict]) -> datetime | None:
330
- timestamp_keys = ("recorded_at", "timestamp", "local_time", "raw_time", "latest_time")
331
- latest: datetime | None = None
332
- for row in rows:
333
- for key in timestamp_keys:
334
- value = row.get(key)
335
- if value is None:
336
- continue
337
- parsed = self._parse_datetime(value)
338
- if parsed is None:
339
- continue
340
- if latest is None or parsed > latest:
341
- latest = parsed
342
- break
343
- return latest
344
-
345
- def _resolve_asset(
346
- self,
347
- requested_asset_id: str | None,
348
- planner_result: dict,
349
- question: str,
350
- candidates: list,
351
- user_id: str = "user_1",
352
- ) -> object:
353
- if requested_asset_id:
354
- asset = live_asset_registry.get_asset(requested_asset_id, user_id=user_id)
355
- if asset is not None:
356
- return asset
357
-
358
- direct_match = live_asset_registry.best_direct_match(question, user_id=user_id)
359
- if direct_match is not None:
360
- return direct_match
361
-
362
- planned_asset_id = planner_result.get("asset_id", "")
363
- if planned_asset_id:
364
- asset = live_asset_registry.get_asset(planned_asset_id, user_id=user_id)
365
- if asset is not None:
366
- return asset
367
-
368
- available = list(dict.fromkeys(asset.name for asset in (candidates[:12] or live_asset_registry.list_assets(user_id)[:12])))
369
- detail = "I could not identify the asset from your message."
370
- if available:
371
- detail += f" Available assets include: {', '.join(available)}."
372
- raise HTTPException(status_code=422, detail=detail)
373
-
374
- def _parse_time_window(self, raw_time_window: dict | None) -> TimeWindow:
375
- if not raw_time_window:
376
- return self._default_analysis_time_window()
377
- try:
378
- return TimeWindow(**raw_time_window)
379
- except Exception:
380
- return self._default_analysis_time_window()
381
-
382
- def _default_analysis_time_window(self) -> TimeWindow:
383
- return TimeWindow(scope="historical", value=3, unit="hours", label="last 3 hours")
384
-
385
- def _question_mentions_time_window(self, question: str) -> bool:
386
- text = question.lower()
387
- if self._extract_explicit_time_range_window(question) is not None:
388
- return True
389
- if self._extract_relative_duration_window(question) is not None:
390
- return True
391
- if self._extract_relative_day_window(question) is not None:
392
- return True
393
- if self._extract_specific_date_window(question) is not None:
394
- return True
395
-
396
- time_words = (
397
- "today",
398
- "yesterday",
399
- "tomorrow",
400
- "last",
401
- "past",
402
- "previous",
403
- "between",
404
- "from",
405
- "to",
406
- "since",
407
- "until",
408
- "till",
409
- "hour",
410
- "hours",
411
- "minute",
412
- "minutes",
413
- "day",
414
- "days",
415
- "week",
416
- "weeks",
417
- "month",
418
- "months",
419
- )
420
- return any(word in text for word in time_words)
421
-
422
- def _build_query_window(self, time_window: TimeWindow) -> dict[str, str]:
423
- timezone = ZoneInfo("Asia/Karachi")
424
- if time_window.start_at and time_window.end_at:
425
- started_at = datetime.fromisoformat(time_window.start_at)
426
- ended_at = datetime.fromisoformat(time_window.end_at)
427
- friendly_format = "%d %b %Y, %I:%M %p"
428
- return {
429
- "started_at_iso": started_at.isoformat(),
430
- "ended_at_iso": ended_at.isoformat(),
431
- "label": (
432
- f"{started_at.strftime(friendly_format)} to "
433
- f"{ended_at.strftime(friendly_format)} (Asia/Karachi)"
434
- ),
435
- }
436
-
437
- ended_at = datetime.now(timezone)
438
-
439
- unit_to_delta = {
440
- "minutes": timedelta(minutes=time_window.value),
441
- "hours": timedelta(hours=time_window.value),
442
- "days": timedelta(days=time_window.value),
443
- "weeks": timedelta(weeks=time_window.value),
444
- "months": timedelta(days=30 * time_window.value),
445
- }
446
- started_at = ended_at - unit_to_delta.get(time_window.unit, timedelta(hours=3))
447
- friendly_format = "%d %b %Y, %I:%M %p"
448
-
449
- return {
450
- "started_at_iso": started_at.isoformat(),
451
- "ended_at_iso": ended_at.isoformat(),
452
- "label": (
453
- f"{started_at.strftime(friendly_format)} to "
454
- f"{ended_at.strftime(friendly_format)} (Asia/Karachi)"
455
- ),
456
- }
457
-
458
- def _extract_specific_date_window(self, question: str) -> tuple[datetime, datetime, str] | None:
459
- timezone = ZoneInfo("Asia/Karachi")
460
- patterns = [
461
- r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b",
462
- r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b",
463
- r"\b(\d{1,2})-(\d{1,2})-(\d{4})\b",
464
- ]
465
-
466
- for pattern in patterns:
467
- match = re.search(pattern, question)
468
- if not match:
469
- continue
470
- groups = match.groups()
471
- try:
472
- if pattern.startswith(r"\b(\d{4})"):
473
- year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
474
- else:
475
- day, month, year = int(groups[0]), int(groups[1]), int(groups[2])
476
- anchor_date = datetime(year, month, day, 6, 0, 0, tzinfo=timezone)
477
- end_date = anchor_date + timedelta(days=1)
478
- label = (
479
- f"{anchor_date.strftime('%d %b %Y')} 06:00 AM to "
480
- f"{end_date.strftime('%d %b %Y')} 06:00 AM"
481
- )
482
- return anchor_date, end_date, label
483
- except ValueError:
484
- continue
485
-
486
- month_name_patterns = [
487
- r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\s*,?\s*(\d{4})\b",
488
- r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\s*,?\s*(\d{4})\b",
489
- r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\b",
490
- r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\b",
491
- ]
492
- month_map = {
493
- "jan": 1, "january": 1,
494
- "feb": 2, "february": 2,
495
- "mar": 3, "march": 3,
496
- "apr": 4, "april": 4,
497
- "may": 5,
498
- "jun": 6, "june": 6,
499
- "jul": 7, "july": 7,
500
- "aug": 8, "august": 8,
501
- "sep": 9, "sept": 9, "september": 9,
502
- "oct": 10, "october": 10,
503
- "nov": 11, "november": 11,
504
- "dec": 12, "decebmber": 12, "december": 12,
505
- }
506
- now_local = datetime.now(timezone)
507
- for pattern in month_name_patterns:
508
- match = re.search(pattern, question, flags=re.IGNORECASE)
509
- if not match:
510
- continue
511
- groups = match.groups()
512
- try:
513
- if pattern.startswith(r"\b(\d"):
514
- day = int(groups[0])
515
- month = month_map.get(groups[1].lower())
516
- year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year
517
- else:
518
- month = month_map.get(groups[0].lower())
519
- day = int(groups[1])
520
- year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year
521
- if month is None:
522
- continue
523
- anchor_date = datetime(year, month, day, 6, 0, 0, tzinfo=timezone)
524
- end_date = anchor_date + timedelta(days=1)
525
- label = (
526
- f"{anchor_date.strftime('%d %b %Y')} 06:00 AM to "
527
- f"{end_date.strftime('%d %b %Y')} 06:00 AM"
528
- )
529
- return anchor_date, end_date, label
530
- except ValueError:
531
- continue
532
-
533
- ordinal_only = re.search(r"\b(\d{1,2})(?:st|nd|rd|th)\b", question, flags=re.IGNORECASE)
534
- if ordinal_only:
535
- day = int(ordinal_only.group(1))
536
- year = now_local.year
537
- month = now_local.month
538
- if day > now_local.day:
539
- if month == 1:
540
- month = 12
541
- year -= 1
542
- else:
543
- month -= 1
544
- try:
545
- anchor_date = datetime(year, month, day, 6, 0, 0, tzinfo=timezone)
546
- end_date = anchor_date + timedelta(days=1)
547
- label = (
548
- f"{anchor_date.strftime('%d %b %Y')} 06:00 AM to "
549
- f"{end_date.strftime('%d %b %Y')} 06:00 AM"
550
- )
551
- return anchor_date, end_date, label
552
- except ValueError:
553
- return None
554
- return None
555
-
556
- def _extract_relative_duration_window(self, question: str) -> TimeWindow | None:
557
- lowered = question.lower()
558
-
559
- unit_map = {
560
- "h": "hours",
561
- "hr": "hours",
562
- "hrs": "hours",
563
- "hour": "hours",
564
- "hours": "hours",
565
- "d": "days",
566
- "day": "days",
567
- "days": "days",
568
- "w": "weeks",
569
- "wk": "weeks",
570
- "wks": "weeks",
571
- "week": "weeks",
572
- "weeks": "weeks",
573
- "m": "months",
574
- "mo": "months",
575
- "mon": "months",
576
- "month": "months",
577
- "months": "months",
578
- "min": "minutes",
579
- "mins": "minutes",
580
- "minute": "minutes",
581
- "minutes": "minutes",
582
- }
583
-
584
- def build_window(value: int, raw_unit: str) -> TimeWindow | None:
585
- unit = unit_map.get(raw_unit.lower())
586
- if unit is None:
587
- return None
588
- return TimeWindow(
589
- scope="historical",
590
- value=value,
591
- unit=unit,
592
- label=f"last {value} {unit}",
593
- )
594
-
595
- 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"
596
- match = re.search(pattern, lowered)
597
- if match:
598
- built = build_window(int(match.group(1)), match.group(2))
599
- if built is not None:
600
- return built
601
-
602
- 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"
603
- match_single = re.search(pattern_single, lowered)
604
- if match_single:
605
- built = build_window(1, match_single.group(1))
606
- if built is not None:
607
- return built
608
-
609
- pattern_bare = r"\b(\d+)\s*(h|hr|hrs?|hours?|d|days?|w|wk|wks|weeks?|m|mo|mon|months?|min|mins?|minutes?)\b"
610
- match_bare = re.search(pattern_bare, lowered)
611
- if match_bare:
612
- built = build_window(int(match_bare.group(1)), match_bare.group(2))
613
- if built is not None:
614
- return built
615
-
616
- return None
617
-
618
- def _extract_explicit_time_range_window(self, question: str) -> tuple[datetime, datetime, str] | None:
619
- timezone = ZoneInfo("Asia/Karachi")
620
- lowered = question.lower()
621
- range_match = re.search(
622
- 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",
623
- lowered,
624
- flags=re.IGNORECASE,
625
- )
626
- if not range_match:
627
- return None
628
-
629
- start_hm = self._parse_clock_token(range_match.group(1))
630
- end_hm = self._parse_clock_token(range_match.group(2))
631
- if start_hm is None or end_hm is None:
632
- return None
633
-
634
- anchor = self._extract_anchor_date(question, timezone) or datetime.now(timezone).date()
635
- started_at = datetime(anchor.year, anchor.month, anchor.day, start_hm[0], start_hm[1], tzinfo=timezone)
636
- ended_at = datetime(anchor.year, anchor.month, anchor.day, end_hm[0], end_hm[1], tzinfo=timezone)
637
- if ended_at <= started_at:
638
- ended_at += timedelta(days=1)
639
-
640
- friendly_format = "%d %b %Y, %I:%M %p"
641
- label = f"{started_at.strftime(friendly_format)} to {ended_at.strftime(friendly_format)} (Asia/Karachi)"
642
- return started_at, ended_at, label
643
-
644
- def _parse_clock_token(self, token: str) -> tuple[int, int] | None:
645
- match = re.match(r"^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*$", token.strip(), flags=re.IGNORECASE)
646
- if not match:
647
- return None
648
- hour = int(match.group(1))
649
- minute = int(match.group(2) or "0")
650
- meridian = match.group(3).lower()
651
- if hour < 1 or hour > 12 or minute < 0 or minute > 59:
652
- return None
653
- if meridian == "am":
654
- hour = 0 if hour == 12 else hour
655
- else:
656
- hour = 12 if hour == 12 else hour + 12
657
- return hour, minute
658
-
659
- def _extract_anchor_date(self, question: str, timezone: ZoneInfo) -> date | None:
660
- patterns = [
661
- r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b",
662
- r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b",
663
- r"\b(\d{1,2})-(\d{1,2})-(\d{4})\b",
664
- ]
665
-
666
- for pattern in patterns:
667
- match = re.search(pattern, question)
668
- if not match:
669
- continue
670
- groups = match.groups()
671
- try:
672
- if pattern.startswith(r"\b(\d{4})"):
673
- year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
674
- else:
675
- day, month, year = int(groups[0]), int(groups[1]), int(groups[2])
676
- return date(year, month, day)
677
- except ValueError:
678
- continue
679
-
680
- month_name_patterns = [
681
- r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\s*,?\s*(\d{4})\b",
682
- r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\s*,?\s*(\d{4})\b",
683
- r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\b",
684
- r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\b",
685
- ]
686
- month_map = {
687
- "jan": 1, "january": 1,
688
- "feb": 2, "february": 2,
689
- "mar": 3, "march": 3,
690
- "apr": 4, "april": 4,
691
- "may": 5,
692
- "jun": 6, "june": 6,
693
- "jul": 7, "july": 7,
694
- "aug": 8, "august": 8,
695
- "sep": 9, "sept": 9, "september": 9,
696
- "oct": 10, "october": 10,
697
- "nov": 11, "november": 11,
698
- "dec": 12, "decebmber": 12, "december": 12,
699
- }
700
- now_local = datetime.now(timezone)
701
- for pattern in month_name_patterns:
702
- match = re.search(pattern, question, flags=re.IGNORECASE)
703
- if not match:
704
- continue
705
- groups = match.groups()
706
- try:
707
- if pattern.startswith(r"\b(\d"):
708
- day = int(groups[0])
709
- month = month_map.get(groups[1].lower())
710
- year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year
711
- else:
712
- month = month_map.get(groups[0].lower())
713
- day = int(groups[1])
714
- year = int(groups[2]) if len(groups) > 2 and groups[2] else now_local.year
715
- if month is None:
716
- continue
717
- return date(year, month, day)
718
- except ValueError:
719
- continue
720
-
721
- ordinal_only = re.search(r"\b(\d{1,2})(?:st|nd|rd|th)\b", question, flags=re.IGNORECASE)
722
- if ordinal_only:
723
- day = int(ordinal_only.group(1))
724
- year = now_local.year
725
- month = now_local.month
726
- if day > now_local.day:
727
- if month == 1:
728
- month = 12
729
- year -= 1
730
- else:
731
- month -= 1
732
- try:
733
- return date(year, month, day)
734
- except ValueError:
735
- return None
736
- return None
737
-
738
- def _extract_relative_day_window(self, question: str) -> tuple[datetime, datetime, str] | None:
739
- text = question.lower()
740
- timezone = ZoneInfo("Asia/Karachi")
741
- now_local = datetime.now(timezone)
742
-
743
- if "today" in text or "scheduled" in text:
744
- shift_start = now_local.replace(hour=6, minute=0, second=0, microsecond=0)
745
- if now_local < shift_start:
746
- shift_start = shift_start - timedelta(days=1)
747
- label = (
748
- f"{shift_start.strftime('%d %b %Y')} 06:00 AM to "
749
- f"{now_local.strftime('%d %b %Y %I:%M %p')}"
750
- )
751
- return shift_start, now_local, label
752
-
753
- if "yesterday" in text:
754
- today_shift_start = now_local.replace(hour=6, minute=0, second=0, microsecond=0)
755
- if now_local < today_shift_start:
756
- today_shift_start = today_shift_start - timedelta(days=1)
757
- yesterday_shift_start = today_shift_start - timedelta(days=1)
758
- label = (
759
- f"{yesterday_shift_start.strftime('%d %b %Y')} 06:00 AM to "
760
- f"{today_shift_start.strftime('%d %b %Y')} 06:00 AM"
761
- )
762
- return yesterday_shift_start, today_shift_start, label
763
-
764
- return None
765
-
766
- def _clip_rows_to_window(self, rows: list[dict], time_window: TimeWindow) -> list[dict]:
767
- if not rows or not time_window.start_at or not time_window.end_at:
768
- return rows
769
- try:
770
- start_dt = datetime.fromisoformat(time_window.start_at)
771
- end_dt = datetime.fromisoformat(time_window.end_at)
772
- except ValueError:
773
- return rows
774
-
775
- timestamp_keys = ("recorded_at", "timestamp", "local_time", "raw_time")
776
- filtered: list[dict] = []
777
- for row in rows:
778
- row_ts = None
779
- for key in timestamp_keys:
780
- value = row.get(key)
781
- if value is None:
782
- continue
783
- row_ts = self._parse_datetime(value)
784
- if row_ts is not None:
785
- break
786
- if row_ts is None:
787
- filtered.append(row)
788
- continue
789
-
790
- if row_ts.tzinfo is None and start_dt.tzinfo is not None:
791
- row_ts = row_ts.replace(tzinfo=start_dt.tzinfo)
792
- if start_dt <= row_ts < end_dt:
793
- filtered.append(row)
794
- return filtered
795
-
796
- def _parse_datetime(self, value: object) -> datetime | None:
797
- if isinstance(value, datetime):
798
- return value
799
- text = str(value).strip()
800
- if not text:
801
- return None
802
- try:
803
- return datetime.fromisoformat(text.replace("Z", "+00:00"))
804
- except ValueError:
805
- pass
806
- for fmt in (
807
- "%d %b %Y, %I:%M:%S %p",
808
- "%d %b %Y, %I:%M %p",
809
- "%d-%m-%Y %I:%M %p",
810
- "%Y-%m-%d %H:%M:%S",
811
- "%Y-%m-%d %H:%M",
812
- ):
813
- try:
814
- return datetime.strptime(text, fmt)
815
- except ValueError:
816
- continue
817
- return None
818
-
819
- def _default_planner_result(self) -> dict:
820
- return {
821
- "asset_id": "",
822
- "analysis_name": "live_status",
823
- "time_window": {
824
- "scope": "historical",
825
- "value": 3,
826
- "unit": "hours",
827
- "label": "last 3 hours",
828
- },
829
- "reasoning": "Planner bypassed.",
830
- "prompt_text": "[BYPASSED]",
831
- }
832
-
833
- def _build_asset_catalog_context(self, candidates: list) -> str:
834
- shortlisted = candidates[:4] if candidates else live_asset_registry.list_assets()[:5]
835
- entries: list[str] = []
836
- total_chars = 0
837
- max_chars = 2400
838
- for asset in shortlisted:
839
- aliases = [
840
- alias for alias in asset.lookup_names
841
- if alias and alias.lower() != asset.name.lower()
842
- ][:2]
843
- entry = json.dumps(
844
- {
845
- "asset_id": asset.asset_id,
846
- "name": asset.name,
847
- "aliases": aliases,
848
- "type": asset.asset_type,
849
- "parent": asset.parent_name or "",
850
- "child_count": asset.child_count,
851
- "has_live_data": asset.has_live_data,
852
- "data_keys": asset.data_keys[:4],
853
- "supported_analyses": asset.supported_analyses[:3],
854
- }
855
- )
856
- if total_chars + len(entry) > max_chars:
857
- break
858
- entries.append(entry)
859
- total_chars += len(entry)
860
- return "\n".join(entries)
861
-
862
- def _chart_hints_for_analysis(self, analysis_name: str) -> list[str]:
863
- mapping = {
864
- "cooling_cycle_analysis": ["line_chart", "event_markers"],
865
- "consumption_analysis": ["line_chart", "duration_summary", "event_markers"],
866
- "live_status": ["stat_cards"],
867
- "trend_analysis": ["line_chart"],
868
- "historical_variance": ["line_chart", "box_plot"],
869
- "throughput_utilization": ["bar_chart", "line_chart"],
870
- "inventory_level_analysis": ["line_chart", "threshold_bands"],
871
- "anomaly_detection": ["line_chart", "anomaly_table"],
872
- }
873
- return mapping.get(analysis_name, ["line_chart"])
874
-
875
-
876
- 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,1542 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
- from datetime import datetime
5
-
6
- from agents.analysis.backend.app.core.config import get_settings
7
- from agents.analysis.backend.app.models.schemas import AssetProfile, TimeWindow
8
- from agents.analysis.backend.app.services.sql_guard import validate_read_only_sql
9
-
10
-
11
- @dataclass
12
- class SQLPlan:
13
- query: str
14
- explanation: str
15
-
16
-
17
- class NLToSQLService:
18
- def __init__(self) -> None:
19
- self._settings = get_settings()
20
-
21
- def build_query(
22
- self,
23
- asset: AssetProfile,
24
- question: str,
25
- analysis_name: str,
26
- time_window: TimeWindow,
27
- ) -> SQLPlan:
28
- query = self._build_asset_query(asset=asset, analysis_name=analysis_name, time_window=time_window)
29
- validate_read_only_sql(query)
30
-
31
- explanation = (
32
- f"Generated a read-only OpenRemote query for `{asset.name}` using "
33
- f"`{analysis_name}` over {time_window.label}."
34
- )
35
- return SQLPlan(query=query, explanation=explanation)
36
-
37
- def normalize_generated_query(self, query: str, asset: AssetProfile) -> str:
38
- normalized = query
39
- normalized = normalized.replace("ad.asset_id", "ad.entity_id")
40
- normalized = normalized.replace("ad.AssetId", "ad.entity_id")
41
- normalized = normalized.replace("asset_datapoint.asset_id", "asset_datapoint.entity_id")
42
-
43
- if "JOIN openremote.asset_datapoint ad ON ad.entity_id = a.id" not in normalized:
44
- normalized = normalized.replace(
45
- "JOIN openremote.asset_datapoint ad ON a.id = ad.entity_id",
46
- "JOIN openremote.asset_datapoint ad ON ad.entity_id = a.id",
47
- )
48
-
49
- stripped = normalized.lstrip()
50
- if "descendants AS" in normalized and stripped.startswith("WITH ") and not stripped.startswith("WITH RECURSIVE "):
51
- normalized = normalized.replace("WITH ", "WITH RECURSIVE ", 1)
52
-
53
- return normalized
54
-
55
- def _build_asset_query(self, asset: AssetProfile, analysis_name: str, time_window: TimeWindow) -> str:
56
- if not asset.is_device and asset.child_count > 0:
57
- if asset.asset_type == "tank":
58
- return self._build_tank_group_query(asset, time_window)
59
- if asset.asset_type == "smoke_alarm":
60
- return self._build_smoke_group_query(asset, time_window)
61
- if asset.asset_type == "energy_meter":
62
- return self._build_energy_group_query(asset, time_window)
63
- if asset.asset_type == "aqi":
64
- return self._build_aqi_group_query(asset, time_window)
65
- return self._build_group_query(asset, time_window)
66
- if asset.asset_type == "cold_room":
67
- return self._build_cold_room_query(asset, time_window)
68
- if asset.asset_type == "filling_machine":
69
- return self._build_filling_machine_query(asset, time_window)
70
- if asset.asset_type == "tank":
71
- return self._build_tank_query(asset, time_window)
72
- if asset.asset_type == "smoke_alarm":
73
- return self._build_smoke_device_query(asset, time_window)
74
- return self._build_generic_query(asset, time_window)
75
-
76
- def _build_smoke_group_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
77
- root_id = self._escape_sql(asset.db_asset_id or asset.asset_id)
78
- offset = self._settings.local_time_offset_hours
79
- timestamp_window_filter = self._window_filter_sql("ad.timestamp", time_window, local=False)
80
- sampled = self._is_multi_day_window(time_window)
81
- per_day_points = self._points_per_day(time_window)
82
- if sampled:
83
- return f"""
84
- WITH RECURSIVE hierarchy AS (
85
- SELECT id, name, parent_id, attributes::jsonb AS attributes, attributes::jsonb AS static_attributes
86
- FROM {self._settings.openremote_schema}.asset
87
- WHERE id = '{root_id}'
88
- UNION ALL
89
- SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes, c.attributes::jsonb AS static_attributes
90
- FROM {self._settings.openremote_schema}.asset c
91
- JOIN hierarchy h ON c.parent_id = h.id
92
- ),
93
- devices AS (
94
- SELECT id, name, static_attributes
95
- FROM hierarchy
96
- WHERE id != '{root_id}'
97
- ),
98
- raw_points AS (
99
- SELECT
100
- d.name AS asset_name,
101
- d.static_attributes,
102
- ad.attribute_name,
103
- (ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
104
- ad.value AS sensor_data,
105
- 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,
106
- NULLIF(regexp_replace(COALESCE(ad.value->>'temp', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS temperature,
107
- NULLIF(regexp_replace(COALESCE(ad.value->>'humi', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS humidity,
108
- NULLIF(regexp_replace(COALESCE(ad.value->>'bat_percent', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_percent,
109
- 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,
110
- CASE
111
- 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
112
- ELSE 0
113
- END AS smoke_detected,
114
- COALESCE(ad.value->>'name', ad.attribute_name) AS sensor_name,
115
- date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
116
- FROM devices d
117
- LEFT JOIN {self._settings.openremote_schema}.asset_datapoint ad ON ad.entity_id = d.id
118
- WHERE 1=1
119
- {timestamp_window_filter}
120
- ),
121
- bucketed AS (
122
- SELECT
123
- rp.*,
124
- ntile({per_day_points}) OVER (
125
- PARTITION BY rp.asset_name, rp.attribute_name, rp.day_bucket
126
- ORDER BY rp.timestamp
127
- ) AS sample_bucket
128
- FROM raw_points rp
129
- ),
130
- sampled_points AS (
131
- SELECT *
132
- FROM (
133
- SELECT
134
- b.*,
135
- row_number() OVER (
136
- PARTITION BY b.asset_name, b.attribute_name, b.day_bucket, b.sample_bucket
137
- ORDER BY b.timestamp DESC
138
- ) AS rn
139
- FROM bucketed b
140
- ) ranked
141
- WHERE rn = 1
142
- )
143
- SELECT
144
- asset_name,
145
- static_attributes,
146
- attribute_name,
147
- timestamp AS recorded_at,
148
- sensor_name,
149
- CASE
150
- WHEN warn_raw IN ('warn', 'mute', 'low-vol', 'fault', 'ok', 'remove', 'install', 'ok-vol-test', 'low-vol-test') THEN warn_raw
151
- WHEN warn_raw IN ('17', '0x17') THEN 'ok'
152
- WHEN warn_raw IN ('11', '0x11') THEN 'warn'
153
- WHEN warn_raw IN ('12', '0x12') THEN 'mute'
154
- WHEN warn_raw IN ('14', '0x14') THEN 'low-vol'
155
- WHEN warn_raw IN ('15', '0x15') THEN 'fault'
156
- WHEN warn_raw IN ('1a', '0x1a', '26') THEN 'remove'
157
- WHEN warn_raw IN ('1b', '0x1b', '27') THEN 'install'
158
- WHEN warn_raw IN ('1e', '0x1e', '30') THEN 'ok-vol-test'
159
- WHEN warn_raw IN ('1f', '0x1f', '31') THEN 'low-vol-test'
160
- WHEN warn_raw = '' THEN NULL
161
- ELSE 'unknown'
162
- END AS warn_type,
163
- temperature,
164
- humidity,
165
- bat_percent,
166
- bat_voltage,
167
- smoke_detected,
168
- sensor_data
169
- FROM sampled_points
170
- ORDER BY asset_name, recorded_at ASC, attribute_name;
171
- """.strip()
172
- return f"""
173
- WITH RECURSIVE hierarchy AS (
174
- SELECT id, name, parent_id, attributes::jsonb AS attributes, attributes::jsonb AS static_attributes
175
- FROM {self._settings.openremote_schema}.asset
176
- WHERE id = '{root_id}'
177
- UNION ALL
178
- SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes, c.attributes::jsonb AS static_attributes
179
- FROM {self._settings.openremote_schema}.asset c
180
- JOIN hierarchy h ON c.parent_id = h.id
181
- ),
182
- devices AS (
183
- SELECT id, name, static_attributes
184
- FROM hierarchy
185
- WHERE id != '{root_id}'
186
- ),
187
- raw_points AS (
188
- SELECT
189
- d.name AS asset_name,
190
- d.static_attributes,
191
- ad.attribute_name,
192
- (ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
193
- ad.value AS sensor_data,
194
- LOWER(COALESCE(ad.value->>'warn', '')) AS warn_raw,
195
- NULLIF(regexp_replace(COALESCE(ad.value->>'temp', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS temperature,
196
- NULLIF(regexp_replace(COALESCE(ad.value->>'humi', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS humidity,
197
- NULLIF(regexp_replace(COALESCE(ad.value->>'bat_percent', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_percent,
198
- 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,
199
- CASE
200
- 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
201
- ELSE 0
202
- END AS smoke_detected,
203
- COALESCE(ad.value->>'name', ad.attribute_name) AS sensor_name
204
- FROM devices d
205
- LEFT JOIN {self._settings.openremote_schema}.asset_datapoint ad ON ad.entity_id = d.id
206
- WHERE 1=1
207
- {timestamp_window_filter}
208
- )
209
- SELECT
210
- asset_name,
211
- static_attributes,
212
- attribute_name,
213
- timestamp AS recorded_at,
214
- sensor_name,
215
- CASE
216
- WHEN warn_raw IN ('warn', 'mute', 'low-vol', 'fault', 'ok', 'remove', 'install', 'ok-vol-test', 'low-vol-test') THEN warn_raw
217
- WHEN warn_raw IN ('17', '0x17') THEN 'ok'
218
- WHEN warn_raw IN ('11', '0x11') THEN 'warn'
219
- WHEN warn_raw IN ('12', '0x12') THEN 'mute'
220
- WHEN warn_raw IN ('14', '0x14') THEN 'low-vol'
221
- WHEN warn_raw IN ('15', '0x15') THEN 'fault'
222
- WHEN warn_raw IN ('1a', '0x1a', '26') THEN 'remove'
223
- WHEN warn_raw IN ('1b', '0x1b', '27') THEN 'install'
224
- WHEN warn_raw IN ('1e', '0x1e', '30') THEN 'ok-vol-test'
225
- WHEN warn_raw IN ('1f', '0x1f', '31') THEN 'low-vol-test'
226
- WHEN warn_raw = '' THEN NULL
227
- ELSE 'unknown'
228
- END AS warn_type,
229
- temperature,
230
- humidity,
231
- bat_percent,
232
- bat_voltage,
233
- smoke_detected,
234
- sensor_data
235
- FROM raw_points
236
- ORDER BY asset_name, recorded_at ASC, attribute_name;
237
- """.strip()
238
-
239
- def _build_smoke_device_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
240
- exact_match = self._exact_asset_match_sql(asset.name)
241
- offset = self._settings.local_time_offset_hours
242
- timestamp_window_filter = self._window_filter_sql("ad.timestamp", time_window, local=False)
243
- sampled = self._is_multi_day_window(time_window)
244
- per_day_points = self._points_per_day(time_window)
245
- if sampled:
246
- return f"""
247
- WITH raw_points AS (
248
- SELECT
249
- a.id::text AS asset_id,
250
- a.name AS asset_name,
251
- a.attributes::jsonb AS static_attributes,
252
- ad.attribute_name,
253
- (ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
254
- ad.value AS sensor_data,
255
- LOWER(COALESCE(ad.value->>'warn', '')) AS warn_raw,
256
- NULLIF(regexp_replace(COALESCE(ad.value->>'temp', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS temperature,
257
- NULLIF(regexp_replace(COALESCE(ad.value->>'humi', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS humidity,
258
- NULLIF(regexp_replace(COALESCE(ad.value->>'bat_percent', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_percent,
259
- NULLIF(regexp_replace(COALESCE(ad.value->>'bat_voltage', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_voltage,
260
- CASE
261
- WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('warn', 'mute', '11', '0x11', '12', '0x12', '1', 'true') THEN 1
262
- ELSE 0
263
- END AS smoke_detected,
264
- COALESCE(ad.value->>'name', ad.attribute_name) AS sensor_name,
265
- date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
266
- FROM {self._settings.openremote_schema}.asset a
267
- JOIN {self._settings.openremote_schema}.asset_datapoint ad ON ad.entity_id = a.id
268
- WHERE {exact_match}
269
- {timestamp_window_filter}
270
- ),
271
- bucketed AS (
272
- SELECT
273
- rp.*,
274
- ntile({per_day_points}) OVER (
275
- PARTITION BY rp.asset_id, rp.attribute_name, rp.day_bucket
276
- ORDER BY rp.timestamp
277
- ) AS sample_bucket
278
- FROM raw_points rp
279
- ),
280
- sampled_points AS (
281
- SELECT *
282
- FROM (
283
- SELECT
284
- b.*,
285
- row_number() OVER (
286
- PARTITION BY b.asset_id, b.attribute_name, b.day_bucket, b.sample_bucket
287
- ORDER BY b.timestamp DESC
288
- ) AS rn
289
- FROM bucketed b
290
- ) ranked
291
- WHERE rn = 1
292
- )
293
- SELECT
294
- asset_id,
295
- asset_name,
296
- static_attributes,
297
- attribute_name,
298
- timestamp AS recorded_at,
299
- sensor_name,
300
- CASE
301
- WHEN warn_raw IN ('warn', 'mute', 'low-vol', 'fault', 'ok', 'remove', 'install', 'ok-vol-test', 'low-vol-test') THEN warn_raw
302
- WHEN warn_raw IN ('17', '0x17') THEN 'ok'
303
- WHEN warn_raw IN ('11', '0x11') THEN 'warn'
304
- WHEN warn_raw IN ('12', '0x12') THEN 'mute'
305
- WHEN warn_raw IN ('14', '0x14') THEN 'low-vol'
306
- WHEN warn_raw IN ('15', '0x15') THEN 'fault'
307
- WHEN warn_raw IN ('1a', '0x1a', '26') THEN 'remove'
308
- WHEN warn_raw IN ('1b', '0x1b', '27') THEN 'install'
309
- WHEN warn_raw IN ('1e', '0x1e', '30') THEN 'ok-vol-test'
310
- WHEN warn_raw IN ('1f', '0x1f', '31') THEN 'low-vol-test'
311
- WHEN warn_raw = '' THEN NULL
312
- ELSE 'unknown'
313
- END AS warn_type,
314
- temperature,
315
- humidity,
316
- bat_percent,
317
- bat_voltage,
318
- smoke_detected,
319
- sensor_data
320
- FROM sampled_points
321
- ORDER BY recorded_at ASC, attribute_name;
322
- """.strip()
323
- return f"""
324
- SELECT
325
- a.id::text AS asset_id,
326
- a.name AS asset_name,
327
- a.attributes::jsonb AS static_attributes,
328
- ad.attribute_name,
329
- (ad.timestamp + INTERVAL '{offset} hours') AS recorded_at,
330
- COALESCE(ad.value->>'name', ad.attribute_name) AS sensor_name,
331
- CASE
332
- 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')
333
- WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('17', '0x17') THEN 'ok'
334
- WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('11', '0x11') THEN 'warn'
335
- WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('12', '0x12') THEN 'mute'
336
- WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('14', '0x14') THEN 'low-vol'
337
- WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('15', '0x15') THEN 'fault'
338
- WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('1a', '0x1a', '26') THEN 'remove'
339
- WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('1b', '0x1b', '27') THEN 'install'
340
- WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('1e', '0x1e', '30') THEN 'ok-vol-test'
341
- WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('1f', '0x1f', '31') THEN 'low-vol-test'
342
- WHEN COALESCE(ad.value->>'warn', '') = '' THEN NULL
343
- ELSE 'unknown'
344
- END AS warn_type,
345
- NULLIF(regexp_replace(COALESCE(ad.value->>'temp', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS temperature,
346
- NULLIF(regexp_replace(COALESCE(ad.value->>'humi', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS humidity,
347
- NULLIF(regexp_replace(COALESCE(ad.value->>'bat_percent', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_percent,
348
- NULLIF(regexp_replace(COALESCE(ad.value->>'bat_voltage', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS bat_voltage,
349
- CASE
350
- WHEN LOWER(COALESCE(ad.value->>'warn', '')) IN ('warn', 'mute', '11', '0x11', '12', '0x12', '1', 'true') THEN 1
351
- ELSE 0
352
- END AS smoke_detected,
353
- (ad.timestamp + INTERVAL '{offset} hours') AS recorded_at,
354
- ad.value AS sensor_data
355
- FROM {self._settings.openremote_schema}.asset a
356
- JOIN {self._settings.openremote_schema}.asset_datapoint ad ON ad.entity_id = a.id
357
- WHERE {exact_match}
358
- {timestamp_window_filter}
359
- ORDER BY recorded_at ASC, ad.attribute_name;
360
- """.strip()
361
-
362
- def _build_group_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
363
- interval = self._interval_sql(time_window)
364
- root_id = self._escape_sql(asset.db_asset_id or asset.asset_id)
365
- offset = self._settings.local_time_offset_hours
366
- timestamp_window_filter = self._window_filter_sql("ad.timestamp", time_window, local=False)
367
- sampled = self._is_multi_day_window(time_window)
368
- per_day_points = self._points_per_day(time_window)
369
- if sampled:
370
- return f"""
371
- WITH RECURSIVE hierarchy AS (
372
- SELECT id, name, parent_id, attributes::jsonb AS attributes
373
- FROM {self._settings.openremote_schema}.asset
374
- WHERE id = '{root_id}'
375
- UNION ALL
376
- SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
377
- FROM {self._settings.openremote_schema}.asset c
378
- JOIN hierarchy h ON c.parent_id = h.id
379
- ),
380
- devices AS (
381
- SELECT id, name
382
- FROM hierarchy
383
- WHERE id != '{root_id}'
384
- AND attributes IS NOT NULL
385
- AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
386
- ),
387
- raw_points AS (
388
- SELECT
389
- ad.entity_id,
390
- ad.attribute_name,
391
- (ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
392
- ad.value,
393
- date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
394
- FROM {self._settings.openremote_schema}.asset_datapoint ad
395
- JOIN devices d ON d.id = ad.entity_id
396
- WHERE 1=1
397
- {timestamp_window_filter}
398
- ),
399
- bucketed AS (
400
- SELECT
401
- rp.*,
402
- ntile({per_day_points}) OVER (
403
- PARTITION BY rp.entity_id, rp.day_bucket
404
- ORDER BY rp.timestamp
405
- ) AS sample_bucket
406
- FROM raw_points rp
407
- ),
408
- sampled_points AS (
409
- SELECT *
410
- FROM (
411
- SELECT
412
- b.*,
413
- row_number() OVER (
414
- PARTITION BY b.entity_id, b.day_bucket, b.sample_bucket
415
- ORDER BY b.timestamp DESC
416
- ) AS rn
417
- FROM bucketed b
418
- ) ranked
419
- WHERE rn = 1
420
- )
421
- SELECT
422
- d.name AS asset_name,
423
- sp.attribute_name,
424
- sp.timestamp,
425
- sp.value AS sensor_data
426
- FROM devices d
427
- LEFT JOIN sampled_points sp ON sp.entity_id = d.id
428
- ORDER BY d.name, sp.timestamp, sp.attribute_name;
429
- """.strip()
430
- return f"""
431
- WITH RECURSIVE hierarchy AS (
432
- SELECT id, name, parent_id, attributes::jsonb AS attributes
433
- FROM {self._settings.openremote_schema}.asset
434
- WHERE id = '{root_id}'
435
- UNION ALL
436
- SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
437
- FROM {self._settings.openremote_schema}.asset c
438
- JOIN hierarchy h ON c.parent_id = h.id
439
- ),
440
- devices AS (
441
- SELECT id, name
442
- FROM hierarchy
443
- WHERE id != '{root_id}'
444
- AND attributes IS NOT NULL
445
- AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
446
- ),
447
- latest_points AS (
448
- SELECT DISTINCT ON (ad.entity_id, ad.attribute_name)
449
- ad.entity_id,
450
- ad.attribute_name,
451
- (ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
452
- ad.value
453
- FROM {self._settings.openremote_schema}.asset_datapoint ad
454
- JOIN devices d ON d.id = ad.entity_id
455
- WHERE 1=1
456
- {timestamp_window_filter}
457
- ORDER BY ad.entity_id, ad.attribute_name, ad.timestamp DESC
458
- )
459
- SELECT
460
- d.name AS asset_name,
461
- lp.attribute_name,
462
- lp.timestamp,
463
- lp.value AS sensor_data
464
- FROM devices d
465
- LEFT JOIN latest_points lp ON lp.entity_id = d.id
466
- ORDER BY d.name, lp.attribute_name;
467
- """.strip()
468
-
469
- def _build_energy_group_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
470
- root_id = self._escape_sql(asset.db_asset_id or asset.asset_id)
471
- offset = self._settings.local_time_offset_hours
472
- local_time_window_filter = self._window_filter_sql(
473
- f"(ad.timestamp + INTERVAL '{offset} hours')",
474
- time_window,
475
- local=True,
476
- )
477
- sampled = self._is_multi_day_window(time_window)
478
- per_day_points = self._points_per_day(time_window)
479
- if sampled:
480
- return f"""
481
- WITH RECURSIVE hierarchy AS (
482
- SELECT id, name, parent_id, attributes::jsonb AS attributes
483
- FROM {self._settings.openremote_schema}.asset
484
- WHERE id = '{root_id}'
485
- UNION ALL
486
- SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
487
- FROM {self._settings.openremote_schema}.asset c
488
- JOIN hierarchy h ON c.parent_id = h.id
489
- ),
490
- devices AS (
491
- SELECT id, name
492
- FROM hierarchy
493
- WHERE id != '{root_id}'
494
- AND attributes IS NOT NULL
495
- AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
496
- ),
497
- raw_points AS (
498
- SELECT
499
- d.name AS asset_name,
500
- ad.attribute_name,
501
- ad.timestamp AS raw_time,
502
- (ad.timestamp + INTERVAL '{offset} hours') AS local_time,
503
- ad.value AS sensor_data,
504
- NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentA', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_a,
505
- NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentB', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_b,
506
- NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentC', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_c,
507
- NULLIF(regexp_replace(COALESCE(ad.value->>'PowerA', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS power_a,
508
- NULLIF(regexp_replace(COALESCE(ad.value->>'PowerB', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS power_b,
509
- NULLIF(regexp_replace(COALESCE(ad.value->>'PowerC', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS power_c,
510
- NULLIF(regexp_replace(COALESCE(ad.value->>'EnergyTotalABC', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS energy_total,
511
- date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
512
- FROM {self._settings.openremote_schema}.asset_datapoint ad
513
- JOIN devices d ON d.id = ad.entity_id
514
- WHERE 1=1
515
- {local_time_window_filter}
516
- ),
517
- bucketed AS (
518
- SELECT
519
- rp.*,
520
- ntile({per_day_points}) OVER (
521
- PARTITION BY rp.asset_name, rp.attribute_name, rp.day_bucket
522
- ORDER BY rp.timestamp
523
- ) AS sample_bucket
524
- FROM raw_points rp
525
- ),
526
- sampled_points AS (
527
- SELECT *
528
- FROM (
529
- SELECT
530
- b.*,
531
- row_number() OVER (
532
- PARTITION BY b.asset_name, b.attribute_name, b.day_bucket, b.sample_bucket
533
- ORDER BY b.local_time DESC
534
- ) AS rn
535
- FROM bucketed b
536
- ) ranked
537
- WHERE rn = 1
538
- )
539
- SELECT
540
- asset_name,
541
- attribute_name,
542
- local_time AS recorded_at,
543
- current_a,
544
- current_b,
545
- current_c,
546
- power_a,
547
- power_b,
548
- power_c,
549
- energy_total,
550
- sensor_data
551
- FROM sampled_points
552
- ORDER BY asset_name, recorded_at ASC, attribute_name;
553
- """.strip()
554
- return f"""
555
- WITH RECURSIVE hierarchy AS (
556
- SELECT id, name, parent_id, attributes::jsonb AS attributes
557
- FROM {self._settings.openremote_schema}.asset
558
- WHERE id = '{root_id}'
559
- UNION ALL
560
- SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
561
- FROM {self._settings.openremote_schema}.asset c
562
- JOIN hierarchy h ON c.parent_id = h.id
563
- ),
564
- devices AS (
565
- SELECT id, name
566
- FROM hierarchy
567
- WHERE id != '{root_id}'
568
- AND attributes IS NOT NULL
569
- AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
570
- ),
571
- raw_points AS (
572
- SELECT
573
- d.name AS asset_name,
574
- ad.attribute_name,
575
- ad.timestamp AS raw_time,
576
- (ad.timestamp + INTERVAL '{offset} hours') AS local_time,
577
- ad.value AS sensor_data,
578
- NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentA', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_a,
579
- NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentB', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_b,
580
- NULLIF(regexp_replace(COALESCE(ad.value->>'CurrentC', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS current_c,
581
- NULLIF(regexp_replace(COALESCE(ad.value->>'PowerA', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS power_a,
582
- NULLIF(regexp_replace(COALESCE(ad.value->>'PowerB', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS power_b,
583
- 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,
584
- 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
585
- FROM {self._settings.openremote_schema}.asset_datapoint ad
586
- JOIN devices d ON d.id = ad.entity_id
587
- WHERE 1=1
588
- {local_time_window_filter}
589
- )
590
- SELECT
591
- asset_name,
592
- attribute_name,
593
- local_time AS recorded_at,
594
- current_a,
595
- current_b,
596
- current_c,
597
- power_a,
598
- power_b,
599
- power_c,
600
- energy_total,
601
- sensor_data
602
- FROM raw_points
603
- ORDER BY asset_name, recorded_at ASC, attribute_name;
604
- """.strip()
605
-
606
- def _build_aqi_group_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
607
- root_id = self._escape_sql(asset.db_asset_id or asset.asset_id)
608
- offset = self._settings.local_time_offset_hours
609
- local_time_window_filter = self._window_filter_sql(
610
- f"(ad.timestamp + INTERVAL '{offset} hours')",
611
- time_window,
612
- local=True,
613
- )
614
- sampled = self._is_multi_day_window(time_window)
615
- per_day_points = self._points_per_day(time_window)
616
- if sampled:
617
- return f"""
618
- WITH RECURSIVE hierarchy AS (
619
- SELECT id, name, parent_id, attributes::jsonb AS attributes
620
- FROM {self._settings.openremote_schema}.asset
621
- WHERE id = '{root_id}'
622
- UNION ALL
623
- SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
624
- FROM {self._settings.openremote_schema}.asset c
625
- JOIN hierarchy h ON c.parent_id = h.id
626
- ),
627
- devices AS (
628
- SELECT id, name
629
- FROM hierarchy
630
- WHERE id != '{root_id}'
631
- AND attributes IS NOT NULL
632
- AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
633
- ),
634
- raw_points AS (
635
- SELECT
636
- d.name AS asset_name,
637
- ad.attribute_name,
638
- ad.timestamp AS raw_time,
639
- (ad.timestamp + INTERVAL '{offset} hours') AS local_time,
640
- ad.value AS sensor_data,
641
- NULLIF(regexp_replace(COALESCE(ad.value->>'AQIIndex001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS aqi_index,
642
- NULLIF(regexp_replace(COALESCE(ad.value->>'pm25', ad.value->>'PM25', ad.value->>'PM2.5', ad.value->>'pm2.5', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS pm25,
643
- NULLIF(regexp_replace(COALESCE(ad.value->>'pm10', ad.value->>'PM10', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS pm10,
644
- NULLIF(regexp_replace(COALESCE(ad.value->>'co2', ad.value->>'CO2', ad.value->>'AQICO2001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS co2,
645
- NULLIF(regexp_replace(COALESCE(ad.value->>'voc', ad.value->>'VOC', ad.value->>'AQIVOC001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS voc,
646
- date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
647
- FROM {self._settings.openremote_schema}.asset_datapoint ad
648
- JOIN devices d ON d.id = ad.entity_id
649
- WHERE 1=1
650
- {local_time_window_filter}
651
- ),
652
- bucketed AS (
653
- SELECT
654
- rp.*,
655
- ntile({per_day_points}) OVER (
656
- PARTITION BY rp.asset_name, rp.attribute_name, rp.day_bucket
657
- ORDER BY rp.raw_time
658
- ) AS sample_bucket
659
- FROM raw_points rp
660
- ),
661
- sampled_points AS (
662
- SELECT *
663
- FROM (
664
- SELECT
665
- b.*,
666
- row_number() OVER (
667
- PARTITION BY b.asset_name, b.attribute_name, b.day_bucket, b.sample_bucket
668
- ORDER BY b.local_time DESC
669
- ) AS rn
670
- FROM bucketed b
671
- ) ranked
672
- WHERE rn = 1
673
- )
674
- SELECT
675
- asset_name,
676
- attribute_name,
677
- local_time AS recorded_at,
678
- aqi_index,
679
- pm25,
680
- pm10,
681
- co2,
682
- voc,
683
- sensor_data
684
- FROM sampled_points
685
- ORDER BY asset_name, recorded_at ASC, attribute_name;
686
- """.strip()
687
- return f"""
688
- WITH RECURSIVE hierarchy AS (
689
- SELECT id, name, parent_id, attributes::jsonb AS attributes
690
- FROM {self._settings.openremote_schema}.asset
691
- WHERE id = '{root_id}'
692
- UNION ALL
693
- SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
694
- FROM {self._settings.openremote_schema}.asset c
695
- JOIN hierarchy h ON c.parent_id = h.id
696
- ),
697
- devices AS (
698
- SELECT id, name
699
- FROM hierarchy
700
- WHERE id != '{root_id}'
701
- AND attributes IS NOT NULL
702
- AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
703
- ),
704
- raw_points AS (
705
- SELECT
706
- d.name AS asset_name,
707
- ad.attribute_name,
708
- ad.timestamp AS raw_time,
709
- (ad.timestamp + INTERVAL '{offset} hours') AS local_time,
710
- ad.value AS sensor_data,
711
- NULLIF(regexp_replace(COALESCE(ad.value->>'AQIIndex001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS aqi_index,
712
- NULLIF(regexp_replace(COALESCE(ad.value->>'pm25', ad.value->>'PM25', ad.value->>'PM2.5', ad.value->>'pm2.5', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS pm25,
713
- NULLIF(regexp_replace(COALESCE(ad.value->>'pm10', ad.value->>'PM10', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS pm10,
714
- NULLIF(regexp_replace(COALESCE(ad.value->>'co2', ad.value->>'CO2', ad.value->>'AQICO2001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS co2,
715
- NULLIF(regexp_replace(COALESCE(ad.value->>'voc', ad.value->>'VOC', ad.value->>'AQIVOC001', ''), '[^0-9\\.-]', '', 'g'), '')::numeric AS voc
716
- FROM {self._settings.openremote_schema}.asset_datapoint ad
717
- JOIN devices d ON d.id = ad.entity_id
718
- WHERE 1=1
719
- {local_time_window_filter}
720
- )
721
- SELECT
722
- asset_name,
723
- attribute_name,
724
- local_time AS recorded_at,
725
- aqi_index,
726
- pm25,
727
- pm10,
728
- co2,
729
- voc,
730
- sensor_data
731
- FROM raw_points
732
- ORDER BY asset_name, recorded_at ASC, attribute_name;
733
- """.strip()
734
-
735
- def _build_tank_group_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
736
- interval = self._interval_sql(time_window)
737
- root_id = self._escape_sql(asset.db_asset_id or asset.asset_id)
738
- offset = self._settings.local_time_offset_hours
739
- timestamp_window_filter = self._window_filter_sql("ad.timestamp", time_window, local=False)
740
- sampled = self._is_multi_day_window(time_window)
741
- per_day_points = self._points_per_day(time_window)
742
- if sampled:
743
- return f"""
744
- WITH RECURSIVE hierarchy AS (
745
- SELECT id, name, parent_id, attributes::jsonb AS attributes
746
- FROM {self._settings.openremote_schema}.asset
747
- WHERE id = '{root_id}'
748
- UNION ALL
749
- SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
750
- FROM {self._settings.openremote_schema}.asset c
751
- JOIN hierarchy h ON c.parent_id = h.id
752
- ),
753
- devices AS (
754
- SELECT id, name, attributes
755
- FROM hierarchy
756
- WHERE id != '{root_id}'
757
- AND attributes IS NOT NULL
758
- AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
759
- ),
760
- raw_level_points AS (
761
- SELECT
762
- ad.entity_id,
763
- ad.attribute_name,
764
- (ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
765
- ad.value,
766
- date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
767
- FROM {self._settings.openremote_schema}.asset_datapoint ad
768
- JOIN devices d ON d.id = ad.entity_id
769
- WHERE 1=1
770
- AND (
771
- NULLIF(COALESCE(ad.value->>'TankOilLevelInFeet001', CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END), '') IS NOT NULL
772
- OR NULLIF(COALESCE(ad.value->>'Level', CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END), '') IS NOT NULL
773
- )
774
- {timestamp_window_filter}
775
- ),
776
- bucketed AS (
777
- SELECT
778
- rp.*,
779
- ntile({per_day_points}) OVER (
780
- PARTITION BY rp.entity_id, rp.day_bucket
781
- ORDER BY rp.timestamp
782
- ) AS sample_bucket
783
- FROM raw_level_points rp
784
- ),
785
- tank_payload AS (
786
- SELECT *
787
- FROM (
788
- SELECT
789
- b.entity_id,
790
- b.attribute_name,
791
- b.timestamp,
792
- b.value,
793
- row_number() OVER (
794
- PARTITION BY b.entity_id, b.day_bucket, b.sample_bucket
795
- ORDER BY b.timestamp DESC
796
- ) AS rn
797
- FROM bucketed b
798
- ) ranked
799
- WHERE rn = 1
800
- ),
801
- capacity_payload AS (
802
- SELECT
803
- d.id AS entity_id,
804
- 'asset.maxcapacity.value' AS attribute_name,
805
- NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric AS max_ft_from_payload
806
- FROM devices d
807
- WHERE NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '') IS NOT NULL
808
- )
809
- SELECT
810
- d.name AS asset_name,
811
- tp.attribute_name AS level_attribute,
812
- COALESCE(cp.attribute_name, 'asset.maxcapacity.value') AS max_ft_attribute,
813
- tp.timestamp,
814
- COALESCE(
815
- NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric,
816
- cp.max_ft_from_payload
817
- ) AS max_ft,
818
- COALESCE(
819
- NULLIF(tp.value->>'TankOilLevelInFeet001', '')::numeric,
820
- NULLIF(CASE WHEN tp.attribute_name = 'TankOilLevelInFeet001' THEN tp.value::text END, '')::numeric,
821
- NULLIF(tp.value->>'Level', '')::numeric,
822
- NULLIF(CASE WHEN tp.attribute_name = 'Level' THEN tp.value::text END, '')::numeric
823
- ) AS tank_oil_level_in_feet_001,
824
- GREATEST(
825
- COALESCE(
826
- NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric,
827
- cp.max_ft_from_payload,
828
- 0
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
- 0
835
- ),
836
- 0
837
- ) AS current_level_ft,
838
- ROUND(
839
- (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)
840
- / NULLIF(COALESCE(NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric, cp.max_ft_from_payload), 0)) * 100,
841
- 1
842
- ) AS percentage_filled,
843
- tp.value AS sensor_data
844
- FROM devices d
845
- LEFT JOIN tank_payload tp ON tp.entity_id = d.id
846
- LEFT JOIN capacity_payload cp ON cp.entity_id = d.id
847
- ORDER BY d.name, tp.timestamp;
848
- """.strip()
849
- return f"""
850
- WITH RECURSIVE hierarchy AS (
851
- SELECT id, name, parent_id, attributes::jsonb AS attributes
852
- FROM {self._settings.openremote_schema}.asset
853
- WHERE id = '{root_id}'
854
- UNION ALL
855
- SELECT c.id, c.name, c.parent_id, c.attributes::jsonb AS attributes
856
- FROM {self._settings.openremote_schema}.asset c
857
- JOIN hierarchy h ON c.parent_id = h.id
858
- ),
859
- devices AS (
860
- SELECT id, name, attributes
861
- FROM hierarchy
862
- WHERE id != '{root_id}'
863
- AND attributes IS NOT NULL
864
- AND (attributes - 'notes' - 'location') != '{{}}'::jsonb
865
- ),
866
- raw_level_points AS (
867
- SELECT
868
- ad.entity_id,
869
- ad.attribute_name,
870
- (ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
871
- ad.value
872
- FROM {self._settings.openremote_schema}.asset_datapoint ad
873
- JOIN devices d ON d.id = ad.entity_id
874
- WHERE 1=1
875
- AND (
876
- NULLIF(COALESCE(ad.value->>'TankOilLevelInFeet001', CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END), '') IS NOT NULL
877
- OR NULLIF(COALESCE(ad.value->>'Level', CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END), '') IS NOT NULL
878
- )
879
- {timestamp_window_filter}
880
- ORDER BY ad.entity_id, ad.timestamp ASC, ad.attribute_name
881
- ),
882
- tank_payload AS (
883
- SELECT
884
- rp.entity_id,
885
- rp.attribute_name,
886
- rp.timestamp,
887
- rp.value
888
- FROM raw_level_points rp
889
- ),
890
- capacity_payload AS (
891
- SELECT
892
- d.id AS entity_id,
893
- 'asset.maxcapacity.value' AS attribute_name,
894
- NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric AS max_ft_from_payload
895
- FROM devices d
896
- WHERE NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '') IS NOT NULL
897
- )
898
- SELECT
899
- d.name AS asset_name,
900
- tp.attribute_name AS level_attribute,
901
- COALESCE(cp.attribute_name, 'asset.maxcapacity.value') AS max_ft_attribute,
902
- tp.timestamp,
903
- COALESCE(
904
- NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric,
905
- cp.max_ft_from_payload
906
- ) AS max_ft,
907
- COALESCE(
908
- NULLIF(tp.value->>'TankOilLevelInFeet001', '')::numeric,
909
- NULLIF(CASE WHEN tp.attribute_name = 'TankOilLevelInFeet001' THEN tp.value::text END, '')::numeric,
910
- NULLIF(tp.value->>'Level', '')::numeric,
911
- NULLIF(CASE WHEN tp.attribute_name = 'Level' THEN tp.value::text END, '')::numeric
912
- ) AS tank_oil_level_in_feet_001,
913
- GREATEST(
914
- COALESCE(
915
- NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric,
916
- cp.max_ft_from_payload,
917
- 0
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
- 0
924
- ),
925
- 0
926
- ) AS current_level_ft,
927
- ROUND(
928
- (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)
929
- / NULLIF(COALESCE(NULLIF(d.attributes->'maxcapacity'->'value'->>'MaxFt', '')::numeric, cp.max_ft_from_payload), 0)) * 100,
930
- 1
931
- ) AS percentage_filled,
932
- tp.value AS sensor_data
933
- FROM devices d
934
- LEFT JOIN tank_payload tp ON tp.entity_id = d.id
935
- LEFT JOIN capacity_payload cp ON cp.entity_id = d.id
936
- ORDER BY d.name, tp.timestamp ASC;
937
- """.strip()
938
-
939
- def _build_cold_room_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
940
- temp_expr = self._coalesce_json_value(asset.temperature_keys or ["Temperature", "temperature"])
941
- humidity_expr = self._coalesce_json_value(asset.humidity_keys or ["Humidity", "humidity"])
942
- offset = self._settings.local_time_offset_hours
943
- interval = self._interval_sql(time_window)
944
- exact_match = self._exact_asset_match_sql(asset.name)
945
- local_time_window_filter = self._window_filter_sql(
946
- f"(ad.timestamp + INTERVAL '{offset} hours')",
947
- time_window,
948
- local=True,
949
- )
950
- sampled = self._is_multi_day_window(time_window)
951
- per_day_points = self._points_per_day(time_window)
952
- if sampled:
953
- return f"""
954
- WITH raw_data AS (
955
- SELECT
956
- a.id AS asset_uuid,
957
- a.name AS asset_name,
958
- ad.timestamp AS raw_time,
959
- (ad.timestamp + INTERVAL '{offset} hours') AS local_time,
960
- regexp_replace({temp_expr}, '[^0-9\\.-]', '', 'g')::numeric AS temperature,
961
- regexp_replace({humidity_expr}, '[^0-9\\.-]', '', 'g')::numeric AS humidity
962
- FROM {self._settings.openremote_schema}.asset_datapoint ad
963
- JOIN {self._settings.openremote_schema}.asset a ON a.id = ad.entity_id
964
- WHERE {exact_match}
965
- AND {temp_expr} IS NOT NULL
966
- AND NULLIF(regexp_replace({temp_expr}, '[^0-9\\.-]', '', 'g'), '') IS NOT NULL
967
- {local_time_window_filter}
968
- ),
969
- bucketed AS (
970
- SELECT
971
- rd.*,
972
- date_trunc('day', rd.local_time) AS day_bucket,
973
- ntile({per_day_points}) OVER (
974
- PARTITION BY rd.asset_uuid, date_trunc('day', rd.local_time)
975
- ORDER BY rd.local_time
976
- ) AS sample_bucket
977
- FROM raw_data rd
978
- ),
979
- sampled_data AS (
980
- SELECT *
981
- FROM (
982
- SELECT
983
- b.*,
984
- row_number() OVER (
985
- PARTITION BY b.asset_uuid, b.day_bucket, b.sample_bucket
986
- ORDER BY b.local_time DESC
987
- ) AS rn
988
- FROM bucketed b
989
- ) ranked
990
- WHERE rn = 1
991
- )
992
- SELECT
993
- asset_uuid::text AS asset_id,
994
- asset_name,
995
- local_time AS recorded_at,
996
- temperature,
997
- humidity,
998
- raw_time < NOW() - INTERVAL '{asset.nominal_range.get("max_stale_hours", 2)} hours' AS is_stale
999
- FROM sampled_data
1000
- ORDER BY recorded_at ASC;
1001
- """.strip()
1002
-
1003
- return f"""
1004
- WITH raw_data AS (
1005
- SELECT
1006
- a.id AS asset_uuid,
1007
- a.name AS asset_name,
1008
- ad.timestamp AS raw_time,
1009
- (ad.timestamp + INTERVAL '{offset} hours') AS local_time,
1010
- regexp_replace({temp_expr}, '[^0-9\\.-]', '', 'g')::numeric AS temperature,
1011
- regexp_replace({humidity_expr}, '[^0-9\\.-]', '', 'g')::numeric AS humidity
1012
- FROM {self._settings.openremote_schema}.asset_datapoint ad
1013
- JOIN {self._settings.openremote_schema}.asset a ON a.id = ad.entity_id
1014
- WHERE {exact_match}
1015
- AND {temp_expr} IS NOT NULL
1016
- AND NULLIF(regexp_replace({temp_expr}, '[^0-9\\.-]', '', 'g'), '') IS NOT NULL
1017
- {local_time_window_filter}
1018
- )
1019
- SELECT
1020
- asset_uuid::text AS asset_id,
1021
- asset_name,
1022
- local_time AS recorded_at,
1023
- temperature,
1024
- humidity,
1025
- raw_time < NOW() - INTERVAL '{asset.nominal_range.get("max_stale_hours", 2)} hours' AS is_stale
1026
- FROM raw_data
1027
- ORDER BY recorded_at DESC
1028
- LIMIT 500;
1029
- """.strip()
1030
-
1031
- def _build_filling_machine_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
1032
- offset = self._settings.local_time_offset_hours
1033
- interval = self._interval_sql(time_window)
1034
- exact_match = self._exact_asset_match_sql(asset.name)
1035
- local_time_window_filter = self._window_filter_sql(
1036
- f"(ad.timestamp + INTERVAL '{offset} hours')",
1037
- time_window,
1038
- local=True,
1039
- )
1040
- sampled = self._is_multi_day_window(time_window)
1041
- per_day_points = self._points_per_day(time_window)
1042
- if sampled:
1043
- return f"""
1044
- WITH aggregated_readings AS (
1045
- SELECT
1046
- a.id AS asset_uuid,
1047
- a.name AS asset_name,
1048
- ad.timestamp AS raw_time,
1049
- jsonb_object_agg(ad.attribute_name, ad.value) AS sensor_data
1050
- FROM {self._settings.openremote_schema}.asset_datapoint ad
1051
- JOIN {self._settings.openremote_schema}.asset a ON a.id = ad.entity_id
1052
- WHERE {exact_match}
1053
- {local_time_window_filter}
1054
- GROUP BY a.id, a.name, ad.timestamp
1055
- ),
1056
- raw_data AS (
1057
- SELECT
1058
- asset_uuid,
1059
- asset_name,
1060
- raw_time,
1061
- (raw_time + INTERVAL '{offset} hours') AS local_time,
1062
- (COALESCE(
1063
- sensor_data->>'prod_shift_1_today',
1064
- sensor_data->'data'->>'prod_shift_1_today'
1065
- ))::numeric AS shift_1_count,
1066
- (COALESCE(
1067
- sensor_data->>'prod_shift_2_today',
1068
- sensor_data->'data'->>'prod_shift_2_today'
1069
- ))::numeric AS shift_2_count,
1070
- (COALESCE(
1071
- sensor_data->>'prod_shift_3_today',
1072
- sensor_data->'data'->>'prod_shift_3_today'
1073
- ))::numeric AS shift_3_count,
1074
- (COALESCE(
1075
- sensor_data->>'prod_total_today',
1076
- sensor_data->'data'->>'prod_total_today'
1077
- ))::numeric AS total_count,
1078
- COALESCE(
1079
- NULLIF(COALESCE(sensor_data->>'Timestamp', sensor_data->'data'->>'Timestamp'), '')::timestamp,
1080
- raw_time + INTERVAL '{offset} hours'
1081
- ) AS payload_time
1082
- FROM aggregated_readings
1083
- WHERE COALESCE(
1084
- sensor_data->>'prod_total_today',
1085
- sensor_data->'data'->>'prod_total_today'
1086
- ) IS NOT NULL
1087
- ),
1088
- bucketed AS (
1089
- SELECT
1090
- rd.*,
1091
- date_trunc('day', rd.local_time) AS day_bucket,
1092
- ntile({per_day_points}) OVER (
1093
- PARTITION BY rd.asset_uuid, date_trunc('day', rd.local_time)
1094
- ORDER BY rd.local_time
1095
- ) AS sample_bucket
1096
- FROM raw_data rd
1097
- ),
1098
- sampled_data AS (
1099
- SELECT *
1100
- FROM (
1101
- SELECT
1102
- b.*,
1103
- row_number() OVER (
1104
- PARTITION BY b.asset_uuid, b.day_bucket, b.sample_bucket
1105
- ORDER BY b.local_time DESC
1106
- ) AS rn
1107
- FROM bucketed b
1108
- ) ranked
1109
- WHERE rn = 1
1110
- )
1111
- SELECT
1112
- asset_uuid::text AS asset_id,
1113
- asset_name,
1114
- local_time AS recorded_at,
1115
- shift_1_count,
1116
- shift_2_count,
1117
- shift_3_count,
1118
- total_count,
1119
- payload_time,
1120
- payload_time < (NOW() + INTERVAL '{offset} hours') - INTERVAL '{asset.nominal_range.get("max_stale_hours", 6)} hours' AS is_stale
1121
- FROM sampled_data
1122
- ORDER BY recorded_at ASC;
1123
- """.strip()
1124
-
1125
- return f"""
1126
- WITH aggregated_readings AS (
1127
- SELECT
1128
- a.id AS asset_uuid,
1129
- a.name AS asset_name,
1130
- ad.timestamp AS raw_time,
1131
- jsonb_object_agg(ad.attribute_name, ad.value) AS sensor_data
1132
- FROM {self._settings.openremote_schema}.asset_datapoint ad
1133
- JOIN {self._settings.openremote_schema}.asset a ON a.id = ad.entity_id
1134
- WHERE {exact_match}
1135
- {local_time_window_filter}
1136
- GROUP BY a.id, a.name, ad.timestamp
1137
- ),
1138
- raw_data AS (
1139
- SELECT
1140
- asset_uuid,
1141
- asset_name,
1142
- raw_time,
1143
- (raw_time + INTERVAL '{offset} hours') AS local_time,
1144
- (COALESCE(
1145
- sensor_data->>'prod_shift_1_today',
1146
- sensor_data->'data'->>'prod_shift_1_today'
1147
- ))::numeric AS shift_1_count,
1148
- (COALESCE(
1149
- sensor_data->>'prod_shift_2_today',
1150
- sensor_data->'data'->>'prod_shift_2_today'
1151
- ))::numeric AS shift_2_count,
1152
- (COALESCE(
1153
- sensor_data->>'prod_shift_3_today',
1154
- sensor_data->'data'->>'prod_shift_3_today'
1155
- ))::numeric AS shift_3_count,
1156
- (COALESCE(
1157
- sensor_data->>'prod_total_today',
1158
- sensor_data->'data'->>'prod_total_today'
1159
- ))::numeric AS total_count,
1160
- COALESCE(
1161
- NULLIF(COALESCE(sensor_data->>'Timestamp', sensor_data->'data'->>'Timestamp'), '')::timestamp,
1162
- raw_time + INTERVAL '{offset} hours'
1163
- ) AS payload_time
1164
- FROM aggregated_readings
1165
- WHERE COALESCE(
1166
- sensor_data->>'prod_total_today',
1167
- sensor_data->'data'->>'prod_total_today'
1168
- ) IS NOT NULL
1169
- )
1170
- SELECT
1171
- asset_uuid::text AS asset_id,
1172
- asset_name,
1173
- local_time AS recorded_at,
1174
- shift_1_count,
1175
- shift_2_count,
1176
- shift_3_count,
1177
- total_count,
1178
- payload_time,
1179
- payload_time < (NOW() + INTERVAL '{offset} hours') - INTERVAL '{asset.nominal_range.get("max_stale_hours", 6)} hours' AS is_stale
1180
- FROM raw_data
1181
- ORDER BY recorded_at DESC
1182
- LIMIT 500;
1183
- """.strip()
1184
-
1185
-
1186
- def _build_tank_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
1187
- offset = self._settings.local_time_offset_hours
1188
- interval = self._interval_sql(time_window)
1189
- exact_match = self._exact_asset_match_sql(asset.name)
1190
- local_time_window_filter = self._window_filter_sql(
1191
- f"(ad.timestamp + INTERVAL '{offset} hours')",
1192
- time_window,
1193
- local=True,
1194
- )
1195
- sampled = self._is_multi_day_window(time_window)
1196
- per_day_points = self._points_per_day(time_window)
1197
- if sampled:
1198
- return f"""
1199
- WITH tank_data AS (
1200
- SELECT
1201
- a.id AS asset_uuid,
1202
- a.name AS asset_name,
1203
- ad.attribute_name,
1204
- ad.timestamp AS raw_time,
1205
- (ad.timestamp + INTERVAL '{offset} hours') AS local_time,
1206
- ROUND(
1207
- regexp_replace(
1208
- COALESCE(
1209
- ad.value->>'TankOilLevelInFeet001',
1210
- CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
1211
- ad.value->>'Level',
1212
- CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
1213
- ),
1214
- '[^0-9\\.-]',
1215
- '',
1216
- 'g'
1217
- )::numeric,
1218
- 2
1219
- ) AS tank_oil_level_in_feet_001
1220
- FROM {self._settings.openremote_schema}.asset_datapoint ad
1221
- JOIN {self._settings.openremote_schema}.asset a ON a.id = ad.entity_id
1222
- WHERE {exact_match}
1223
- AND COALESCE(
1224
- ad.value->>'TankOilLevelInFeet001',
1225
- CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
1226
- ad.value->>'Level',
1227
- CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
1228
- ) IS NOT NULL
1229
- AND NULLIF(
1230
- regexp_replace(
1231
- COALESCE(
1232
- ad.value->>'TankOilLevelInFeet001',
1233
- CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
1234
- ad.value->>'Level',
1235
- CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
1236
- ),
1237
- '[^0-9\\.-]',
1238
- '',
1239
- 'g'
1240
- ),
1241
- ''
1242
- ) IS NOT NULL
1243
- {local_time_window_filter}
1244
- ),
1245
- bucketed AS (
1246
- SELECT
1247
- td.*,
1248
- date_trunc('day', td.local_time) AS day_bucket,
1249
- ntile({per_day_points}) OVER (
1250
- PARTITION BY td.asset_uuid, date_trunc('day', td.local_time)
1251
- ORDER BY td.local_time
1252
- ) AS sample_bucket
1253
- FROM tank_data td
1254
- ),
1255
- sampled_tank_data AS (
1256
- SELECT *
1257
- FROM (
1258
- SELECT
1259
- b.*,
1260
- row_number() OVER (
1261
- PARTITION BY b.asset_uuid, b.day_bucket, b.sample_bucket
1262
- ORDER BY b.local_time DESC
1263
- ) AS rn
1264
- FROM bucketed b
1265
- ) ranked
1266
- WHERE rn = 1
1267
- ),
1268
- latest_capacity AS (
1269
- SELECT
1270
- a.id AS asset_uuid,
1271
- 'asset.maxcapacity.value' AS attribute_name,
1272
- NULLIF(a.attributes::jsonb->'maxcapacity'->'value'->>'MaxFt', '')::numeric AS max_ft
1273
- FROM {self._settings.openremote_schema}.asset a
1274
- WHERE {exact_match}
1275
- AND NULLIF(a.attributes::jsonb->'maxcapacity'->'value'->>'MaxFt', '') IS NOT NULL
1276
- ),
1277
- ordered AS (
1278
- SELECT
1279
- td.asset_uuid,
1280
- td.asset_name,
1281
- td.attribute_name AS level_attribute,
1282
- td.raw_time,
1283
- td.local_time,
1284
- lc.attribute_name AS max_ft_attribute,
1285
- lc.max_ft,
1286
- td.tank_oil_level_in_feet_001,
1287
- GREATEST(COALESCE(lc.max_ft, 0) - COALESCE(td.tank_oil_level_in_feet_001, 0), 0) AS current_level_ft,
1288
- LAG(GREATEST(COALESCE(lc.max_ft, 0) - COALESCE(td.tank_oil_level_in_feet_001, 0), 0))
1289
- OVER (PARTITION BY asset_name ORDER BY local_time ASC) AS previous_level
1290
- FROM sampled_tank_data td
1291
- LEFT JOIN latest_capacity lc ON lc.asset_uuid = td.asset_uuid
1292
- )
1293
- SELECT
1294
- asset_uuid::text AS asset_id,
1295
- asset_name,
1296
- level_attribute,
1297
- max_ft_attribute,
1298
- local_time AS recorded_at,
1299
- max_ft,
1300
- tank_oil_level_in_feet_001,
1301
- current_level_ft,
1302
- ROUND((current_level_ft / NULLIF(max_ft, 0)) * 100, 1) AS percentage_filled,
1303
- CASE
1304
- WHEN previous_level IS NULL THEN 0
1305
- ELSE ROUND(current_level_ft - previous_level, 2)
1306
- END AS level_change,
1307
- raw_time < NOW() - INTERVAL '{asset.nominal_range.get("max_stale_hours", 1)} hours' AS is_stale
1308
- FROM ordered
1309
- ORDER BY recorded_at ASC;
1310
- """.strip()
1311
-
1312
- return f"""
1313
- WITH tank_data AS (
1314
- SELECT
1315
- a.id AS asset_uuid,
1316
- a.name AS asset_name,
1317
- ad.attribute_name,
1318
- ad.timestamp AS raw_time,
1319
- (ad.timestamp + INTERVAL '{offset} hours') AS local_time,
1320
- ROUND(
1321
- regexp_replace(
1322
- COALESCE(
1323
- ad.value->>'TankOilLevelInFeet001',
1324
- CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
1325
- ad.value->>'Level',
1326
- CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
1327
- ),
1328
- '[^0-9\\.-]',
1329
- '',
1330
- 'g'
1331
- )::numeric,
1332
- 2
1333
- ) AS tank_oil_level_in_feet_001
1334
- FROM {self._settings.openremote_schema}.asset_datapoint ad
1335
- JOIN {self._settings.openremote_schema}.asset a ON a.id = ad.entity_id
1336
- WHERE {exact_match}
1337
- AND COALESCE(
1338
- ad.value->>'TankOilLevelInFeet001',
1339
- CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
1340
- ad.value->>'Level',
1341
- CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
1342
- ) IS NOT NULL
1343
- AND NULLIF(
1344
- regexp_replace(
1345
- COALESCE(
1346
- ad.value->>'TankOilLevelInFeet001',
1347
- CASE WHEN ad.attribute_name = 'TankOilLevelInFeet001' THEN ad.value::text END,
1348
- ad.value->>'Level',
1349
- CASE WHEN ad.attribute_name = 'Level' THEN ad.value::text END
1350
- ),
1351
- '[^0-9\\.-]',
1352
- '',
1353
- 'g'
1354
- ),
1355
- ''
1356
- ) IS NOT NULL
1357
- {local_time_window_filter}
1358
- ),
1359
- latest_capacity AS (
1360
- SELECT
1361
- a.id AS asset_uuid,
1362
- 'asset.maxcapacity.value' AS attribute_name,
1363
- NULLIF(a.attributes::jsonb->'maxcapacity'->'value'->>'MaxFt', '')::numeric AS max_ft
1364
- FROM {self._settings.openremote_schema}.asset a
1365
- WHERE {exact_match}
1366
- AND NULLIF(a.attributes::jsonb->'maxcapacity'->'value'->>'MaxFt', '') IS NOT NULL
1367
- ),
1368
- ordered AS (
1369
- SELECT
1370
- td.asset_uuid,
1371
- td.asset_name,
1372
- td.attribute_name AS level_attribute,
1373
- td.raw_time,
1374
- td.local_time,
1375
- lc.attribute_name AS max_ft_attribute,
1376
- lc.max_ft,
1377
- td.tank_oil_level_in_feet_001,
1378
- GREATEST(COALESCE(lc.max_ft, 0) - COALESCE(td.tank_oil_level_in_feet_001, 0), 0) AS current_level_ft,
1379
- LAG(GREATEST(COALESCE(lc.max_ft, 0) - COALESCE(td.tank_oil_level_in_feet_001, 0), 0))
1380
- OVER (PARTITION BY asset_name ORDER BY local_time ASC) AS previous_level
1381
- FROM tank_data td
1382
- LEFT JOIN latest_capacity lc ON lc.asset_uuid = td.asset_uuid
1383
- )
1384
- SELECT
1385
- asset_uuid::text AS asset_id,
1386
- asset_name,
1387
- level_attribute,
1388
- max_ft_attribute,
1389
- local_time AS recorded_at,
1390
- max_ft,
1391
- tank_oil_level_in_feet_001,
1392
- current_level_ft,
1393
- ROUND((current_level_ft / NULLIF(max_ft, 0)) * 100, 1) AS percentage_filled,
1394
- CASE
1395
- WHEN previous_level IS NULL THEN 0
1396
- ELSE ROUND(current_level_ft - previous_level, 2)
1397
- END AS level_change,
1398
- raw_time < NOW() - INTERVAL '{asset.nominal_range.get("max_stale_hours", 1)} hours' AS is_stale
1399
- FROM ordered
1400
- ORDER BY recorded_at DESC
1401
- LIMIT 500;
1402
- """.strip()
1403
-
1404
- def _build_generic_query(self, asset: AssetProfile, time_window: TimeWindow) -> str:
1405
- interval = self._interval_sql(time_window)
1406
- exact_match = self._exact_asset_match_sql(asset.name)
1407
- offset = self._settings.local_time_offset_hours
1408
- timestamp_window_filter = self._window_filter_sql("ad.timestamp", time_window, local=False)
1409
- sampled = self._is_multi_day_window(time_window)
1410
- per_day_points = self._points_per_day(time_window)
1411
- if sampled:
1412
- return f"""
1413
- WITH raw_data AS (
1414
- SELECT
1415
- a.id::text AS asset_id,
1416
- a.name AS asset_name,
1417
- ad.attribute_name,
1418
- (ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
1419
- ad.value AS sensor_data,
1420
- date_trunc('day', ad.timestamp + INTERVAL '{offset} hours') AS day_bucket
1421
- FROM {self._settings.openremote_schema}.asset a
1422
- JOIN {self._settings.openremote_schema}.asset_datapoint ad ON ad.entity_id = a.id
1423
- WHERE {exact_match}
1424
- {timestamp_window_filter}
1425
- ),
1426
- bucketed AS (
1427
- SELECT
1428
- rd.*,
1429
- ntile({per_day_points}) OVER (
1430
- PARTITION BY rd.asset_id, rd.attribute_name, rd.day_bucket
1431
- ORDER BY rd.timestamp
1432
- ) AS sample_bucket
1433
- FROM raw_data rd
1434
- ),
1435
- sampled_data AS (
1436
- SELECT *
1437
- FROM (
1438
- SELECT
1439
- b.*,
1440
- row_number() OVER (
1441
- PARTITION BY b.asset_id, b.attribute_name, b.day_bucket, b.sample_bucket
1442
- ORDER BY b.timestamp DESC
1443
- ) AS rn
1444
- FROM bucketed b
1445
- ) ranked
1446
- WHERE rn = 1
1447
- )
1448
- SELECT
1449
- asset_id,
1450
- asset_name,
1451
- attribute_name,
1452
- (ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
1453
- sensor_data
1454
- FROM sampled_data
1455
- ORDER BY timestamp ASC, attribute_name;
1456
- """.strip()
1457
- return f"""
1458
- SELECT
1459
- a.id::text AS asset_id,
1460
- a.name AS asset_name,
1461
- ad.attribute_name,
1462
- (ad.timestamp + INTERVAL '{offset} hours') AS timestamp,
1463
- ad.value AS sensor_data
1464
- FROM {self._settings.openremote_schema}.asset a
1465
- JOIN {self._settings.openremote_schema}.asset_datapoint ad ON ad.entity_id = a.id
1466
- WHERE {exact_match}
1467
- {timestamp_window_filter}
1468
- ORDER BY ad.timestamp DESC
1469
- LIMIT 500;
1470
- """.strip()
1471
-
1472
- def _coalesce_json_value(self, keys: list[str]) -> str:
1473
- expressions = []
1474
- for key in keys:
1475
- expressions.append(f"ad.value->>'{key}'")
1476
- expressions.append(f"CASE WHEN ad.attribute_name = '{key}' THEN ad.value::text END")
1477
- return f"COALESCE({', '.join(expressions)})"
1478
-
1479
- def _exact_asset_match_sql(self, name: str) -> str:
1480
- escaped = self._escape_sql(name)
1481
- return (
1482
- "REPLACE(LOWER(a.name), ' ', '') = "
1483
- f"REPLACE(LOWER('{escaped}'), ' ', '')"
1484
- )
1485
-
1486
- def _interval_sql(self, time_window: TimeWindow) -> str:
1487
- unit = time_window.unit.rstrip("s")
1488
- value = max(1, time_window.value)
1489
- return f"{value} {unit}"
1490
-
1491
- def _window_filter_sql(self, column_expr: str, time_window: TimeWindow, local: bool) -> str:
1492
- bounds = self._absolute_window_bounds(time_window)
1493
- offset = self._settings.local_time_offset_hours
1494
- if bounds is not None:
1495
- start_dt, end_dt = bounds
1496
- start_text = start_dt.strftime("%Y-%m-%d %H:%M:%S")
1497
- end_text = end_dt.strftime("%Y-%m-%d %H:%M:%S")
1498
- if not local:
1499
- return (
1500
- f"AND {column_expr} >= TIMESTAMP '{start_text}' - INTERVAL '{offset} hours'\n"
1501
- f" AND {column_expr} < TIMESTAMP '{end_text}' - INTERVAL '{offset} hours'"
1502
- )
1503
- return (
1504
- f"AND {column_expr} >= TIMESTAMP '{start_text}'\n"
1505
- f" AND {column_expr} < TIMESTAMP '{end_text}'"
1506
- )
1507
-
1508
- interval = self._interval_sql(time_window)
1509
- if local:
1510
- return (
1511
- f"AND {column_expr} >= NOW() + INTERVAL '{offset} hours' - INTERVAL '{interval}'"
1512
- )
1513
- return f"AND {column_expr} >= NOW() - INTERVAL '{interval}'"
1514
-
1515
- def _absolute_window_bounds(self, time_window: TimeWindow) -> tuple[datetime, datetime] | None:
1516
- if not time_window.start_at or not time_window.end_at:
1517
- return None
1518
- try:
1519
- start_dt = datetime.fromisoformat(time_window.start_at)
1520
- end_dt = datetime.fromisoformat(time_window.end_at)
1521
- return start_dt, end_dt
1522
- except ValueError:
1523
- return None
1524
-
1525
- def _is_multi_day_window(self, time_window: TimeWindow) -> bool:
1526
- if time_window.unit in {"days", "weeks", "months"}:
1527
- return True
1528
- return time_window.unit == "hours" and time_window.value >= 24
1529
-
1530
- def _points_per_day(self, time_window: TimeWindow) -> int:
1531
- # Keep payload manageable while preserving trend shape for long windows.
1532
- if time_window.unit == "months":
1533
- return 10
1534
- if time_window.unit in {"weeks", "days"}:
1535
- return 12
1536
- return 15
1537
-
1538
- def _escape_sql(self, value: str) -> str:
1539
- return value.replace("'", "''")
1540
-
1541
-
1542
- 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()