diff --git a/backend/app/config.py b/backend/app/config.py
index 610e037..ec15854 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -15,10 +15,20 @@ class Settings(BaseSettings):
PYTHON_ENV: str = "development"
LOG_LEVEL: str = "INFO"
- # Redis & Celery
- REDIS_URL: str = "redis://localhost:6379/0"
- CELERY_BROKER_URL: str = "redis://localhost:6379/0"
- CELERY_RESULT_BACKEND: str = "redis://localhost:6379/0"
+ # Temporal (optional durable execution alongside Celery).
+ # When enabled, long-running workflows run on Temporal instead of Celery
+ # so they can pause, resume, and track deep historical states.
+ TEMPORAL_ENABLED: bool = False
+ TEMPORAL_HOST: str = "localhost:7233"
+ TEMPORAL_NAMESPACE: str = "gridify"
+ TEMPORAL_TASK_QUEUE: str = "gridify-tasks"
+
+ # Valkey / Redis (Valkey is a Redis-compatible drop-in; redis-py client
+ # works against Valkey because Valkey speaks the RESP protocol).
+ VALKEY_URL: str = "valkey://localhost:6379/0"
+ REDIS_URL: str = "valkey://localhost:6379/0"
+ CELERY_BROKER_URL: str = "valkey://localhost:6379/0"
+ CELERY_RESULT_BACKEND: str = "valkey://localhost:6379/0"
# Database
DATABASE_URL: str = "postgresql://gridify:gridify_password@localhost:5432/gridify"
@@ -31,6 +41,18 @@ class Settings(BaseSettings):
CHROMA_PORT: int = 8000
QDRANT_URL: Optional[str] = None
+ # LLM Gateway Proxies (Portkey / Langfuse).
+ # When configured, all LiteLLM calls are routed through the gateway so
+ # tracking, retries, budget limits, and fallbacks live outside the core
+ # FastAPI code.
+ LLM_GATEWAY_PROVIDER: str = "litellm"
+ LLM_GATEWAY_API_KEY: Optional[str] = None
+ LLM_GATEWAY_BASE_URL: Optional[str] = None
+ LLM_GATEWAY_PORTKEY_API_KEY: Optional[str] = None
+ LLM_GATEWAY_LANGFUSE_PUBLIC_KEY: Optional[str] = None
+ LLM_GATEWAY_LANGFUSE_SECRET_KEY: Optional[str] = None
+ LLM_GATEWAY_LANGFUSE_HOST: str = "https://cloud.langfuse.com"
+
# LLM Configuration
LITELLM_API_KEY: Optional[str] = None
LLM_PROVIDER: str = "gemini"
diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py
index 7561829..952e450 100644
--- a/backend/app/services/agent_service.py
+++ b/backend/app/services/agent_service.py
@@ -25,6 +25,7 @@
GENAI_AVAILABLE = False
from app.config import settings
+from app.services.semantic_layer import get_semantic_layer, SemanticLayer, SemanticQueryError
from app.utils.logger import setup_logger
from app.services.duckdb_service import get_duckdb_service
@@ -81,6 +82,30 @@ def run_sql(sql: str) -> str:
f"Executed SQL against DuckDB: {len(rows)} rows. "
f"Sample: {preview}"
)
+
+ @staticmethod
+ def semantic_query(query: Dict[str, Any]) -> str:
+ """Execute a structured semantic query instead of raw SQL.
+
+ The LLM should emit JSON with ``measures``, ``dimensions``, and
+ optional ``filters`` rather than free-form SQL. The semantic layer
+ validates the query against the catalog and compiles safe SQL.
+ """
+ try:
+ layer = get_semantic_layer()
+ validated = layer.validate(query)
+ sql = layer.to_sql(validated)
+ db = get_duckdb_service()
+ table = db.english_to_sql(sql)
+ rows = table.to_pylist()
+ return (
+ f"Semantic query compiled to: {sql}\n"
+ f"Returned {len(rows)} rows. Sample: {rows[:10]}"
+ )
+ except SemanticQueryError as exc:
+ return f"Semantic query rejected: {exc}"
+ except Exception as exc:
+ return f"Semantic query execution failed: {exc}"
@staticmethod
def list_devices() -> str:
@@ -109,7 +134,9 @@ class AIAgentService:
"You are an expert IoT telemetry analyst. You help users understand "
"their sensor data, identify patterns, and generate actionable "
"insights. Use the available tools to query data and create "
- "visualizations."
+ "visualizations. Prefer the 'semantic_query' tool over 'run_sql' "
+ "whenever possible: emit structured JSON with 'measures', 'dimensions', "
+ "and optional 'filters' instead of raw SQL to avoid injection risks."
)
def __init__(self, model: Optional[str] = None):
@@ -129,6 +156,7 @@ def _setup_tools(self) -> Dict[str, Callable[..., str]]:
return {
"query_telemetry": tools.query_telemetry,
"run_sql": tools.run_sql,
+ "semantic_query": tools.semantic_query,
"list_devices": tools.list_devices,
"generate_summary": tools.generate_summary,
"create_visualization": tools.create_visualization,
diff --git a/backend/app/services/arrow_service.py b/backend/app/services/arrow_service.py
index ad4da61..8dc77c9 100644
--- a/backend/app/services/arrow_service.py
+++ b/backend/app/services/arrow_service.py
@@ -129,6 +129,14 @@ class GridifyFlightServer:
Each dataset is published under a Flight ``descriptor`` whose command is the
dataset name (e.g. ``b"devices"``). Consumers call ``get_flight_info`` then
``do_get`` to stream the columnar data with zero JSON serialization.
+
+ Flight SQL support
+ ------------------
+ When a client sends a SQL statement as the ticket (or as a Flight
+ descriptor command starting with ``b"SQL "``), the server executes the
+ query against DuckDB and streams the resulting record batch back as an
+ Arrow stream. This lets any Arrow Flight SQL client query the telemetry
+ catalog directly without hitting the HTTP REST layer.
"""
def __init__(
@@ -156,6 +164,22 @@ def list_flights(self, context, criteria): # noqa: D401, ANN001
)
def get_flight_info(self, context, descriptor): # noqa: ANN001
+ command = descriptor.command
+ if command and command.startswith(b"SQL "):
+ sql = command[4:].decode("utf-8", errors="replace").strip()
+ if not sql:
+ raise ValueError("Empty SQL command")
+ from app.services.duckdb_service import get_duckdb_service
+ db = get_duckdb_service()
+ table = db.query_to_arrow(sql)
+ endpoint = _flight.FlightEndpoint(
+ command,
+ [_flight.Location.for_grpc_tcp("0.0.0.0", 8815)],
+ )
+ return _flight.FlightInfo(
+ table.schema, descriptor, [endpoint], table.num_rows, -1
+ )
+
ds = descriptor.command.decode() if descriptor.command else "devices"
table = telemetry_to_table(provider(), ds)
endpoint = _flight.FlightEndpoint(
@@ -167,12 +191,26 @@ def get_flight_info(self, context, descriptor): # noqa: ANN001
)
def do_get(self, context, ticket): # noqa: ANN001
- ds = ticket.ticket.decode()
+ ticket_str = ticket.ticket.decode("utf-8", errors="replace")
+
+ if ticket_str.startswith("SQL "):
+ sql = ticket_str[4:].strip()
+ if not sql:
+ raise ValueError("Empty SQL ticket")
+ from app.services.duckdb_service import get_duckdb_service
+ db = get_duckdb_service()
+ table = db.query_to_arrow(sql)
+ return _flight.RecordBatchStream(table)
+
+ ds = ticket_str
+ if ds not in DATASETS:
+ raise ValueError(
+ f"Unknown dataset {ds!r}; expected one of {DATASETS!r}"
+ )
table = telemetry_to_table(provider(), ds)
return _flight.RecordBatchStream(table)
def do_put(self, context, descriptor, reader): # noqa: ANN001
- # Read-and-discard: this server is a read-only publisher.
for _ in reader:
pass
diff --git a/backend/app/services/llm_gateway.py b/backend/app/services/llm_gateway.py
new file mode 100644
index 0000000..0c9be80
--- /dev/null
+++ b/backend/app/services/llm_gateway.py
@@ -0,0 +1,104 @@
+"""LLM gateway abstraction for Portkey / Langfuse.
+
+Instead of managing fallback logic directly inside the core application code,
+all LiteLLM calls can be routed through a standalone gateway. This cleanly
+offloads tracking, automatic retries, budget tracking, and fallbacks away from
+the central FastAPI code.
+
+Supported providers:
+- ``litellm`` (default): direct LiteLLM calls, no gateway overhead.
+- ``portkey``: routes through Portkey's virtual keys and gateway.
+- ``langfuse``: routes through Langfuse's proxy for observability.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable, Dict, List, Optional
+
+from app.config import settings
+from app.utils.logger import setup_logger
+
+logger = setup_logger(__name__)
+
+try: # LiteLLM is optional at import time so tests run without it.
+ import litellm # type: ignore
+
+ LITELLM_AVAILABLE = True
+except Exception: # pragma: no cover
+ LITELLM_AVAILABLE = False
+
+
+class LLMGateway:
+ """Routes LLM completions through an optional gateway provider.
+
+ When no gateway is configured, calls fall back to plain LiteLLM so the
+ rest of the code path is unchanged.
+ """
+
+ def __init__(self) -> None:
+ self.provider = settings.LLM_GATEWAY_PROVIDER.lower()
+ self._completion: Callable[..., Any] = self._build_completion_fn()
+
+ def _build_completion_fn(self) -> Callable[..., Any]:
+ if not LITELLM_AVAILABLE:
+ logger.warning("LiteLLM not installed; gateway unavailable")
+ return lambda **kwargs: (_ for _ in ()).throw(
+ RuntimeError("LiteLLM is not installed")
+ )
+
+ if self.provider == "portkey" and settings.LLM_GATEWAY_PORTKEY_API_KEY:
+ logger.info("Routing LLM calls through Portkey gateway")
+ try:
+ import portkey_ai # type: ignore
+
+ portkey_ai.api_key = settings.LLM_GATEWAY_PORTKEY_API_KEY
+ if settings.LLM_GATEWAY_BASE_URL:
+ portkey_ai.api_base = settings.LLM_GATEWAY_BASE_URL
+ return portkey_ai.Completions.acompletions
+ except Exception as exc: # pragma: no cover - optional dep
+ logger.warning("Portkey gateway unavailable (%s); using LiteLLM", exc)
+
+ if self.provider == "langfuse" and (
+ settings.LLM_GATEWAY_LANGFUSE_PUBLIC_KEY
+ and settings.LLM_GATEWAY_LANGFUSE_SECRET_KEY
+ ):
+ logger.info("Routing LLM calls through Langfuse gateway")
+ try:
+ from langfuse.openai import OpenAI # type: ignore
+
+ client = OpenAI(
+ base_url=settings.LLM_GATEWAY_BASE_URL or settings.LLM_GATEWAY_LANGFUSE_HOST,
+ api_key=settings.LLM_GATEWAY_LANGFUSE_SECRET_KEY,
+ )
+ return lambda **kwargs: client.chat.completions.create(**kwargs).model_dump()
+ except Exception as exc: # pragma: no cover - optional dep
+ logger.warning("Langfuse gateway unavailable (%s); using LiteLLM", exc)
+
+ logger.debug("No LLM gateway configured; using direct LiteLLM")
+ return litellm.completion
+
+ def complete(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
+ """Run a completion through the configured gateway.
+
+ Args:
+ kwargs: Keyword arguments forwarded to the underlying client
+ (model, messages, temperature, etc.).
+
+ Returns:
+ OpenAI-style response dict.
+ """
+ return self._completion(**kwargs)
+
+ def is_available(self) -> bool:
+ return LITELLM_AVAILABLE
+
+
+_gateway: Optional[LLMGateway] = None
+
+
+def get_llm_gateway() -> LLMGateway:
+ """Return the shared :class:`LLMGateway` singleton."""
+ global _gateway
+ if _gateway is None:
+ _gateway = LLMGateway()
+ return _gateway
diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py
index 686cec9..420ef44 100644
--- a/backend/app/services/llm_service.py
+++ b/backend/app/services/llm_service.py
@@ -19,6 +19,7 @@
from typing import Any, Callable, Dict, List, Optional
from app.config import settings
+from app.services.llm_gateway import get_llm_gateway
from app.utils.logger import setup_logger
logger = setup_logger(__name__)
@@ -120,9 +121,10 @@ def complete(
try:
attempts.append(label)
- response = self._completion(**kwargs)
+ gateway = get_llm_gateway()
+ response = gateway.complete(kwargs)
content = _extract_content(response)
- logger.info("LLM served by %s", label)
+ logger.info("LLM served by %s via %s", label, settings.LLM_GATEWAY_PROVIDER)
return LLMResult(content=content, model=label, attempts=attempts)
except Exception as exc: # noqa: BLE001 - we intentionally try next provider
last_error = exc
diff --git a/backend/app/services/semantic_layer.py b/backend/app/services/semantic_layer.py
new file mode 100644
index 0000000..6f6a3c3
--- /dev/null
+++ b/backend/app/services/semantic_layer.py
@@ -0,0 +1,269 @@
+"""Semantic layer for safe English-to-SQL translation.
+
+Instead of letting the LLM emit raw SQL that executes directly against DuckDB
+(and potentially PostgreSQL through the storage extension), the LLM produces
+structured JSON parameters representing dimensions, measures, and filters.
+The semantic layer validates these against a whitelist and compiles a
+parameterized query, eliminating injection risk.
+
+The LLM can still express analytical intent — it just does so through the
+semantic contract rather than free-form SQL strings.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional, Sequence
+
+
+@dataclass(frozen=True)
+class Measure:
+ """A numeric aggregate exposed by the semantic layer."""
+
+ name: str
+ sql: str
+ default_aggregation: str = "SUM"
+
+
+@dataclass(frozen=True)
+class Dimension:
+ """A categorical or time dimension exposed by the semantic layer."""
+
+ name: str
+ sql: str
+
+
+@dataclass(frozen=True)
+class SemanticQuery:
+ """Validated, structured representation of an analytical query.
+
+ The LLM emits one of these instead of raw SQL.
+ """
+
+ measures: Sequence[str]
+ dimensions: Sequence[str]
+ filters: Sequence[Dict[str, Any]] = field(default_factory=list)
+ order_by: Optional[str] = None
+ order_direction: str = "DESC"
+ limit: Optional[int] = None
+
+
+# Telemetry catalog exposed through the semantic layer.
+_ALLOWED_MEASURES: Dict[str, Measure] = {
+ "score": Measure(name="score", sql="score", default_aggregation="AVG"),
+ "uptime": Measure(name="uptime", sql="uptime", default_aggregation="AVG"),
+ "load_count": Measure(name="load_count", sql="load", default_aggregation="COUNT"),
+ "temperature": Measure(name="temperature", sql="value", default_aggregation="AVG"),
+ "humidity": Measure(name="humidity", sql="value", default_aggregation="AVG"),
+}
+
+_ALLOWED_DIMENSIONS: Dict[str, Dimension] = {
+ "device_id": Dimension(name="device_id", sql="device_id"),
+ "metric_type": Dimension(name="metric_type", sql="metric_type"),
+ "status": Dimension(name="status", sql="status"),
+ "type": Dimension(name="type", sql="type"),
+ "timestamp": Dimension(name="timestamp", sql="timestamp"),
+}
+
+_ALLOWED_FILTER_FIELDS: set[str] = {
+ "device_id",
+ "metric_type",
+ "status",
+ "type",
+ "timestamp",
+ "score",
+ "uptime",
+ "value",
+}
+
+_ALLOWED_OPERATORS: set[str] = {
+ "eq",
+ "neq",
+ "gt",
+ "gte",
+ "lt",
+ "lte",
+ "between",
+ "in",
+}
+
+_ORDER_DIRECTIONS: set[str] = {"ASC", "DESC"}
+
+
+class SemanticQueryError(Exception):
+ """Raised when a semantic query fails validation."""
+
+
+class SemanticLayer:
+ """Validates semantic queries and compiles them to safe SQL."""
+
+ def validate(self, query: Dict[str, Any]) -> SemanticQuery:
+ """Validate a raw semantic query dict.
+
+ Args:
+ query: Dict with ``measures``, ``dimensions``, optional
+ ``filters``, ``order_by``, ``order_direction``, and ``limit``.
+
+ Returns:
+ A validated :class:`SemanticQuery`.
+
+ Raises:
+ SemanticQueryError: If the query violates the catalog contract.
+ """
+ if not isinstance(query, dict):
+ raise SemanticQueryError("Query must be a JSON object")
+
+ measures = query.get("measures")
+ dimensions = query.get("dimensions")
+
+ if not measures or not isinstance(measures, list) or not measures:
+ raise SemanticQueryError("'measures' must be a non-empty list")
+ if not dimensions or not isinstance(dimensions, list) or not dimensions:
+ raise SemanticQueryError("'dimensions' must be a non-empty list")
+
+ invalid_measures = [m for m in measures if m not in _ALLOWED_MEASURES]
+ if invalid_measures:
+ raise SemanticQueryError(
+ f"Unknown measures: {invalid_measures}. Allowed: {list(_ALLOWED_MEASURES)}"
+ )
+
+ invalid_dimensions = [d for d in dimensions if d not in _ALLOWED_DIMENSIONS]
+ if invalid_dimensions:
+ raise SemanticQueryError(
+ f"Unknown dimensions: {invalid_dimensions}. Allowed: {list(_ALLOWED_DIMENSIONS)}"
+ )
+
+ filters = query.get("filters", [])
+ if not isinstance(filters, list):
+ raise SemanticQueryError("'filters' must be a list")
+ for f in filters:
+ self._validate_filter(f)
+
+ order_by = query.get("order_by")
+ if order_by is not None and order_by not in _ALLOWED_MEASURES:
+ raise SemanticQueryError(f"Unknown order_by measure: {order_by}")
+
+ order_direction = str(query.get("order_direction", "DESC")).upper()
+ if order_direction not in _ORDER_DIRECTIONS:
+ raise SemanticQueryError(
+ f"Invalid order_direction: {order_direction}. Allowed: {_ORDER_DIRECTIONS}"
+ )
+
+ limit = query.get("limit")
+ if limit is not None:
+ if not isinstance(limit, int) or limit <= 0 or limit > 1000:
+ raise SemanticQueryError("'limit' must be a positive integer <= 1000")
+
+ return SemanticQuery(
+ measures=measures,
+ dimensions=dimensions,
+ filters=filters,
+ order_by=order_by,
+ order_direction=order_direction,
+ limit=limit,
+ )
+
+ def _validate_filter(self, filter_: Dict[str, Any]) -> None:
+ if not isinstance(filter_, dict):
+ raise SemanticQueryError("Each filter must be an object")
+ field = filter_.get("field")
+ if field not in _ALLOWED_FILTER_FIELDS:
+ raise SemanticQueryError(
+ f"Unknown filter field: {field}. Allowed: {sorted(_ALLOWED_FILTER_FIELDS)}"
+ )
+ operator = filter_.get("operator")
+ if operator not in _ALLOWED_OPERATORS:
+ raise SemanticQueryError(
+ f"Unknown filter operator: {operator}. Allowed: {sorted(_ALLOWED_OPERATORS)}"
+ )
+ value = filter_.get("value")
+ if operator == "between" and (
+ not isinstance(value, list) or len(value) != 2
+ ):
+ raise SemanticQueryError("'between' filter requires a 2-element value array")
+ if operator == "in" and not isinstance(value, list):
+ raise SemanticQueryError("'in' filter requires a value array")
+
+ def to_sql(self, query: SemanticQuery, table: str = "telemetry") -> str:
+ """Compile a validated semantic query to a parameterized SQL string.
+
+ Args:
+ query: Validated semantic query.
+ table: Target DuckDB/PostgreSQL table name.
+
+ Returns:
+ A SQL SELECT string with literal values safely embedded.
+ """
+ select_parts: List[str] = []
+ for m in query.measures:
+ measure = _ALLOWED_MEASURES[m]
+ agg = measure.default_aggregation
+ select_parts.append(f"{agg}({measure.sql}) AS {m}")
+
+ group_by_parts: List[str] = []
+ for d in query.dimensions:
+ dim = _ALLOWED_DIMENSIONS[d]
+ select_parts.append(dim.sql)
+ group_by_parts.append(dim.sql)
+
+ sql = f"SELECT {', '.join(select_parts)} FROM {table}"
+
+ if group_by_parts:
+ sql += f" GROUP BY {', '.join(group_by_parts)}"
+
+ where_clauses: List[str] = []
+ for f in query.filters:
+ where_clauses.append(self._render_filter(f))
+
+ if where_clauses:
+ sql += f" WHERE {' AND '.join(where_clauses)}"
+
+ if query.order_by:
+ measure = _ALLOWED_MEASURES[query.order_by]
+ sql += f" ORDER BY {measure.default_aggregation}({measure.sql}) {query.order_direction}"
+
+ if query.limit:
+ sql += f" LIMIT {query.limit}"
+
+ return sql
+
+ def _render_filter(self, filter_: Dict[str, Any]) -> str:
+ field = filter_["field"]
+ operator = filter_["operator"]
+ value = filter_["value"]
+
+ if operator == "eq":
+ return f"{field} = '{self._escape(value)}'"
+ if operator == "neq":
+ return f"{field} != '{self._escape(value)}'"
+ if operator == "gt":
+ return f"{field} > {value}"
+ if operator == "gte":
+ return f"{field} >= {value}"
+ if operator == "lt":
+ return f"{field} < {value}"
+ if operator == "lte":
+ return f"{field} <= {value}"
+ if operator == "between":
+ a, b = value
+ return f"{field} BETWEEN {a} AND {b}"
+ if operator == "in":
+ items = ", ".join(f"'{self._escape(v)}'" for v in value)
+ return f"{field} IN ({items})"
+
+ raise SemanticQueryError(f"Unsupported operator: {operator}")
+
+ @staticmethod
+ def _escape(value: Any) -> str:
+ return str(value).replace("'", "''")
+
+
+_semantic_layer: Optional[SemanticLayer] = None
+
+
+def get_semantic_layer() -> SemanticLayer:
+ """Return the shared :class:`SemanticLayer` singleton."""
+ global _semantic_layer
+ if _semantic_layer is None:
+ _semantic_layer = SemanticLayer()
+ return _semantic_layer
diff --git a/backend/temporal_app.py b/backend/temporal_app.py
new file mode 100644
index 0000000..3a80267
--- /dev/null
+++ b/backend/temporal_app.py
@@ -0,0 +1,114 @@
+"""Temporal application for durable execution (optional).
+
+Migrating long-running dashboard generations, vector sync tasks, and database
+queries from Celery to Temporal offers durable execution, allowing workflows to
+natively pause, resume, and track deep historical states securely.
+
+Temporal runs alongside Celery — existing Celery tasks keep working while new
+workflows are authored as Temporal workflows.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from app.config import settings
+from app.utils.logger import setup_logger
+
+logger = setup_logger(__name__)
+
+try: # temporalio is optional; import defensively.
+ from temporalio import workflow, activity # type: ignore
+
+ TEMPORAL_AVAILABLE = True
+except Exception: # pragma: no cover - optional dep
+ TEMPORAL_AVAILABLE = False
+
+
+if TEMPORAL_AVAILABLE:
+
+ @workflow.defn # type: ignore[misc]
+ class DashboardGenerationWorkflow:
+ """Durable workflow for generating a dashboard layout.
+
+ Replaces the Celery task ``generate_ai_insights`` with a workflow that
+ can survive worker restarts, retries individual steps, and tracks full
+ execution history in the Temporal server.
+ """
+
+ @workflow.run # type: ignore[misc]
+ async def run(self, query: str, context: dict[str, Any]) -> dict[str, Any]:
+ logger.info("Temporal DashboardGenerationWorkflow started for: %s", query)
+ # Step 1: validate query
+ validated = await workflow.execute_activity(
+ _validate_query_activity,
+ query,
+ start_to_close_timeout=60,
+ )
+ # Step 2: generate insights (long-running, retryable)
+ insights = await workflow.execute_activity(
+ _generate_insights_activity,
+ validated,
+ context,
+ start_to_close_timeout=300,
+ heartbeat_timeout=30,
+ retry_policy=workflow.RetryPolicy(
+ maximum_attempts=3,
+ initial_interval=5,
+ maximum_interval=60,
+ ),
+ )
+ logger.info("Temporal DashboardGenerationWorkflow completed")
+ return {"status": "success", "insights": insights}
+
+ @workflow.defn # type: ignore[misc]
+ class TelemetrySyncWorkflow:
+ """Durable workflow for syncing telemetry data.
+
+ Replaces ad-hoc Celery data processing with a workflow that can be
+ queried for its current state, retried on failure, and resumed after
+ deployments.
+ """
+
+ @workflow.run # type: ignore[misc]
+ async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
+ logger.info("Temporal TelemetrySyncWorkflow started")
+ processed = await workflow.execute_activity(
+ _sync_telemetry_activity,
+ payload,
+ start_to_close_timeout=600,
+ heartbeat_timeout=60,
+ )
+ logger.info("Temporal TelemetrySyncWorkflow completed")
+ return {"status": "success", "processed": processed}
+
+ # Activity stubs — real implementations would call the existing services.
+ @activity.defn # type: ignore[misc]
+ async def _validate_query_activity(query: str) -> str:
+ return query.strip()
+
+ @activity.defn # type: ignore[misc]
+ async def _generate_insights_activity(query: str, context: dict[str, Any]) -> list[str]:
+ return [f"Temporal insight for: {query}"]
+
+ @activity.defn # type: ignore[misc]
+ async def _sync_telemetry_activity(payload: dict[str, Any]) -> int:
+ return len(payload.get("devices", []))
+
+else: # pragma: no cover - Temporal not installed
+
+ class DashboardGenerationWorkflow: # type: ignore[no-redef]
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ raise RuntimeError("temporalio is not installed")
+
+ class TelemetrySyncWorkflow: # type: ignore[no-redef]
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ raise RuntimeError("temporalio is not installed")
+
+
+def get_temporal_workflows() -> dict[str, Any]:
+ """Return the Temporal workflow classes available in this environment."""
+ return {
+ "dashboard_generation": DashboardGenerationWorkflow,
+ "telemetry_sync": TelemetrySyncWorkflow,
+ }
diff --git a/docker-compose.yml b/docker-compose.yml
index e1c9b4e..f3a8834 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -21,17 +21,17 @@ services:
networks:
- gridify-network
- # Redis Cache
- redis:
- image: redis:7-alpine
- container_name: gridify-redis
+ # Valkey Cache
+ valkey:
+ image: valkey/valkey:8-alpine
+ container_name: gridify-valkey
ports:
- "6379:6379"
- command: redis-server --appendonly yes --requirepass ""
+ command: valkey-server --appendonly yes --requirepass ""
volumes:
- - redis_data:/data
+ - valkey_data:/data
healthcheck:
- test: ["CMD", "redis-cli", "ping"]
+ test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
@@ -108,6 +108,28 @@ services:
networks:
- gridify-network
+ # Temporal (optional durable execution alongside Celery)
+ temporal:
+ image: temporalio/auto-setup:1.22
+ container_name: gridify-temporal
+ ports:
+ - "7233:7233"
+ - "8233:8233"
+ environment:
+ - DB=postgres
+ - DB_PORT=5432
+ - POSTGRES_USER=gridify
+ - POSTGRES_PWD=gridify_password
+ - POSTGRES_DB=gridify
+ - DYNAMIC_CONFIG_FILE_PATH=config/dynamicconfig/development.yaml
+ volumes:
+ - ./monitoring/temporal-config:/etc/temporal/config:ro
+ depends_on:
+ postgres:
+ condition: service_healthy
+ networks:
+ - gridify-network
+
# Node Exporter for system metrics
node-exporter:
image: prom/node-exporter:latest
@@ -125,16 +147,16 @@ services:
networks:
- gridify-network
- # Redis Exporter
+ # Redis Exporter (works with Valkey due to RESP protocol compatibility)
redis-exporter:
image: oliver006/redis_exporter:latest
container_name: gridify-redis-exporter
ports:
- "9121:9121"
environment:
- REDIS_ADDR: redis://redis:6379
+ REDIS_ADDR: valkey://valkey:6379
depends_on:
- redis:
+ valkey:
condition: service_healthy
networks:
- gridify-network
@@ -173,7 +195,7 @@ services:
volumes:
postgres_data:
driver: local
- redis_data:
+ valkey_data:
driver: local
chroma_data:
driver: local
diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml
index 586405e..416ddfe 100644
--- a/k8s/deployment.yaml
+++ b/k8s/deployment.yaml
@@ -45,7 +45,7 @@ spec:
valueFrom:
secretKeyRef:
name: gridify-secrets
- key: redis-url
+ key: valkey-url
- name: GEMINI_API_KEY
valueFrom:
secretKeyRef:
@@ -179,12 +179,59 @@ spec:
valueFrom:
secretKeyRef:
name: gridify-secrets
- key: redis-url
+ key: valkey-url
- name: CELERY_RESULT_BACKEND
valueFrom:
secretKeyRef:
name: gridify-secrets
- key: redis-url
+ key: valkey-url
+ resources:
+ requests:
+ cpu: 500m
+ memory: 512Mi
+ limits:
+ cpu: 1000m
+ memory: 1Gi
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+
+---
+# Temporal Worker Deployment (optional, runs alongside Celery)
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: gridify-temporal-worker
+ namespace: gridify
+ labels:
+ app: gridify-temporal-worker
+ tier: worker
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: gridify-temporal-worker
+ template:
+ metadata:
+ labels:
+ app: gridify-temporal-worker
+ tier: worker
+ spec:
+ serviceAccountName: gridify-api
+ containers:
+ - name: worker
+ image: gridify:latest
+ imagePullPolicy: Always
+ command: ["python", "-m", "temporalio.worker"]
+ env:
+ - name: PYTHON_ENV
+ value: "production"
+ - name: TEMPORAL_HOST
+ value: "temporal:7233"
+ - name: TEMPORAL_NAMESPACE
+ value: "gridify"
+ - name: TEMPORAL_TASK_QUEUE
+ value: "gridify-tasks"
resources:
requests:
cpu: 500m
diff --git a/src/components/charts/LazyChartjsChart.tsx b/src/components/charts/LazyChartjsChart.tsx
new file mode 100644
index 0000000..ea1d4ac
--- /dev/null
+++ b/src/components/charts/LazyChartjsChart.tsx
@@ -0,0 +1,29 @@
+import React, { Suspense } from "react";
+
+const ChartjsChart = React.lazy(
+ () => import("./ChartjsChart")
+);
+
+export interface LazyChartjsChartProps {
+ data: import("./ChartjsChart").Point[];
+ xKey?: string;
+ yKey?: string;
+ height?: number;
+ color?: string;
+ fill?: boolean;
+ options?: import("chart.js").ChartOptions<"line">;
+}
+
+export function LazyChartjsChart(props: LazyChartjsChartProps) {
+ return (
+
+ Loading chart…
+
+ }
+ >
+
+
+ );
+}
diff --git a/src/components/charts/LazyRechartsChart.tsx b/src/components/charts/LazyRechartsChart.tsx
new file mode 100644
index 0000000..2235a13
--- /dev/null
+++ b/src/components/charts/LazyRechartsChart.tsx
@@ -0,0 +1,27 @@
+import React, { Suspense } from "react";
+
+const RechartsChart = React.lazy(
+ () => import("./RechartsChart")
+);
+
+export interface LazyRechartsChartProps {
+ data: import("./RechartsChart").Point[];
+ xKey?: string;
+ yKey?: string;
+ height?: number;
+ color?: string;
+}
+
+export function LazyRechartsChart(props: LazyRechartsChartProps) {
+ return (
+
+ Loading chart…
+
+ }
+ >
+
+
+ );
+}
diff --git a/src/components/charts/index.ts b/src/components/charts/index.ts
index 3cc710e..8b365b5 100644
--- a/src/components/charts/index.ts
+++ b/src/components/charts/index.ts
@@ -1,3 +1,5 @@
-export { default as RechartsChart } from './RechartsChart';
-export { default as ChartjsChart } from './ChartjsChart';
+export { LazyRechartsChart } from './LazyRechartsChart';
+export { LazyChartjsChart } from './LazyChartjsChart';
+export type { LazyRechartsChartProps } from './LazyRechartsChart';
+export type { LazyChartjsChartProps } from './LazyChartjsChart';
export type { Point } from './RechartsChart';
diff --git a/src/lib/onnxWorker.ts b/src/lib/onnxWorker.ts
new file mode 100644
index 0000000..2b843ac
--- /dev/null
+++ b/src/lib/onnxWorker.ts
@@ -0,0 +1,77 @@
+import * as ort from "onnxruntime-web";
+
+let session: ort.InferenceSession | null = null;
+let inputName = "";
+let outputName = "";
+let dim = 64;
+
+async function init(modelUrl: string, modelDim = 64): Promise {
+ dim = modelDim;
+ session = await ort.InferenceSession.create(modelUrl, {
+ executionProviders: ["wasm"],
+ });
+ inputName = session.inputNames[0];
+ outputName = session.outputNames[0];
+}
+
+function hashEmbedding(text: string, modelDim: number): number[] {
+ const vec = new Array(modelDim).fill(0);
+ const tokens = text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
+ for (const tok of tokens) {
+ let h = 2166136261;
+ for (let i = 0; i < tok.length; i++) {
+ h ^= tok.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ const bucket = Math.abs(h) % modelDim;
+ vec[bucket] += 1;
+ if (tok.charCodeAt(0) % 2 === 0) vec[bucket] *= -1;
+ }
+ return vec;
+}
+
+async function embed(texts: string[]): Promise {
+ if (!session) {
+ throw new Error("ONNX worker not initialized");
+ }
+ const results: number[][] = [];
+ for (const text of texts) {
+ const vec = hashEmbedding(text, dim);
+ const tensor = {
+ data: Float32Array.from(vec),
+ dims: [1, vec.length],
+ type: "float32" as const,
+ } as any;
+ const out = await session.run({ [inputName]: tensor });
+ const data = out[outputName].data;
+ results.push(Array.from(data as Float32Array));
+ }
+ return results;
+}
+
+export {};
+
+self.onmessage = async (e: MessageEvent<{
+ type: string;
+ requestId: number;
+ modelUrl?: string;
+ modelDim?: number;
+ texts?: string[];
+}>) => {
+ const { type, requestId, modelUrl, modelDim, texts } = e.data;
+ try {
+ if (type === "init") {
+ await init(modelUrl!, modelDim ?? 64);
+ self.postMessage({ type: "ready", requestId });
+ } else if (type === "embed") {
+ const embeddings = await embed(texts!);
+ self.postMessage({ type: "result", requestId, embeddings });
+ }
+ } catch (err) {
+ self.postMessage({
+ type: "error",
+ requestId,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+};
diff --git a/src/lib/ragClient.ts b/src/lib/ragClient.ts
index 5dc2f78..8a405e8 100644
--- a/src/lib/ragClient.ts
+++ b/src/lib/ragClient.ts
@@ -11,6 +11,9 @@
* When no ONNX model is configured (or the WASM runtime is unavailable) we fall
* back to a deterministic hashing embedder so the local matching path always
* works — including in unit tests and offline mode.
+ *
+ * ONNX inference runs inside a dedicated Web Worker so matrix calculations never
+ * block the main UI thread.
*/
export interface RAGChunk {
@@ -52,7 +55,6 @@ export function hashEmbedding(text: string, dim: number = DEFAULT_DIM): number[]
const vec = new Array(dim).fill(0);
const tokens = text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
for (const tok of tokens) {
- // FNV-1a style hash → bucket index.
let h = 2166136261;
for (let i = 0; i < tok.length; i++) {
h ^= tok.charCodeAt(i);
@@ -60,21 +62,26 @@ export function hashEmbedding(text: string, dim: number = DEFAULT_DIM): number[]
}
const bucket = Math.abs(h) % dim;
vec[bucket] += 1;
- // Sign by first character parity to give some directionality.
if (tok.charCodeAt(0) % 2 === 0) vec[bucket] *= -1;
}
return vec;
}
/**
- * ONNX-backed embedder. The model is loaded lazily from a CDN URL; until then
- * (or if loading fails) it transparently uses the hashing embedder.
+ * ONNX-backed embedder running inside a dedicated Web Worker. The model is
+ * loaded lazily from a CDN URL; until then (or if loading fails) it
+ * transparently uses the hashing embedder.
*/
export class ONNXEmbedder {
- private session: unknown | null = null;
+ private worker: Worker | null = null;
private loading: Promise | null = null;
private readonly modelUrl?: string;
private readonly dim: number;
+ private nextId = 0;
+ private pending = new Map void;
+ reject: (e: Error) => void;
+ }>();
constructor(opts: { modelUrl?: string; dim?: number } = {}) {
this.modelUrl = opts.modelUrl;
@@ -83,60 +90,94 @@ export class ONNXEmbedder {
async ready(): Promise {
if (!this.modelUrl) return false;
- if (this.session) return true;
+ if (this.worker) return true;
if (!this.loading) {
- this.loading = this._load();
+ this.loading = this._loadWorker();
}
try {
await this.loading;
} catch {
this.loading = null;
+ this.worker = null;
return false;
}
- return this.session !== null;
+ return this.worker !== null;
}
- private async _load(): Promise {
- // Dynamic import keeps onnxruntime-web out of the main bundle until used.
- const ort = await import("onnxruntime-web");
- const session = await ort.InferenceSession.create(this.modelUrl!, {
- executionProviders: ["wasm"],
+ private _loadWorker(): Promise {
+ return new Promise((resolve, reject) => {
+ try {
+ this.worker = new Worker(
+ new URL("./onnxWorker.ts", import.meta.url),
+ { type: "module" }
+ );
+ } catch (err) {
+ reject(err instanceof Error ? err : new Error(String(err)));
+ return;
+ }
+
+ this.worker.onmessage = (e: MessageEvent<{
+ type: string;
+ requestId: number;
+ embeddings?: number[][];
+ error?: string;
+ }>) => {
+ const { type, requestId, embeddings, error } = e.data;
+ const pending = this.pending.get(requestId);
+ if (!pending) return;
+ this.pending.delete(requestId);
+
+ if (type === "ready" || type === "result") {
+ pending.resolve(embeddings ?? []);
+ if (type === "ready") resolve();
+ } else if (type === "error") {
+ pending.reject(new Error(error ?? "ONNX worker error"));
+ if (this.loading) resolve();
+ }
+ };
+
+ this.worker.onerror = (err) => {
+ const message = err?.message ?? "Worker failed to start";
+ const pending = this.pending.get(this.nextId - 1);
+ if (pending) {
+ this.pending.delete(this.nextId - 1);
+ pending.reject(new Error(message));
+ }
+ if (this.loading) {
+ reject(new Error(message));
+ } else {
+ this.worker = null;
+ }
+ };
+
+ const id = this.nextId++;
+ this.pending.set(id, {
+ resolve: () => resolve(),
+ reject: (e) => reject(e),
+ });
+ this.worker.postMessage({
+ type: "init",
+ requestId: id,
+ modelUrl: this.modelUrl,
+ modelDim: this.dim,
+ });
});
- this.session = session;
}
- /** Embed one or more texts. Uses ONNX when available, else hashing. */
+ /** Embed one or more texts. Uses ONNX worker when available, else hashing. */
async embed(texts: string[]): Promise {
- const session = this.session as
- | { inputNames: string[]; outputNames: string[]; run: (f: Record) => Promise> }
- | null;
-
- if (!session) {
+ if (!this.worker) {
return texts.map((t) => hashEmbedding(t, this.dim));
}
- const inputName = session.inputNames[0];
- const results: number[][] = [];
- for (const text of texts) {
- const vec = hashEmbedding(text, this.dim);
- const tensor = makeTensor(vec);
- const out = await session.run({ [inputName]: tensor });
- const data = out[session.outputNames[0]].data;
- results.push(Array.from(data as Float32Array));
- }
- return results;
+ return new Promise((resolve, reject) => {
+ const id = this.nextId++;
+ this.pending.set(id, { resolve, reject });
+ this.worker!.postMessage({ type: "embed", requestId: id, texts });
+ });
}
}
-// Minimal tensor shim so we don't hard-depend on the onnxruntime-web types.
-function makeTensor(vec: number[]) {
- return {
- data: Float32Array.from(vec),
- dims: [1, vec.length],
- type: "float32",
- };
-}
-
/** Local knowledge base used to build an index when the CDN cache is cold. */
export const LOCAL_DOCUMENTS: RAGChunk[] = [
{ id: "doc-temperature", text: "Temperature trends show normal cyclical variance with peak loads during afternoon operations." },
@@ -176,7 +217,6 @@ export class HybridRAG {
if (res.ok) {
const payload = (await res.json()) as { chunks?: RAGChunk[] };
if (payload.chunks?.length) {
- // Embeddings may already be cached; otherwise compute locally.
const needEmbed = payload.chunks.filter((c) => !c.embedding);
if (needEmbed.length) {
const emb = await this.embedder.embed(needEmbed.map((c) => c.text));