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));