nexaql 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. nexaql/__init__.py +0 -0
  2. nexaql/__main__.py +6 -0
  3. nexaql/adapters/__init__.py +90 -0
  4. nexaql/adapters/base.py +47 -0
  5. nexaql/adapters/duckdb_adapter.py +138 -0
  6. nexaql/adapters/postgresql.py +106 -0
  7. nexaql/api/__init__.py +2 -0
  8. nexaql/api/app.py +58 -0
  9. nexaql/api/deps.py +75 -0
  10. nexaql/api/middleware.py +44 -0
  11. nexaql/api/routes/__init__.py +2 -0
  12. nexaql/api/routes/chat.py +92 -0
  13. nexaql/api/routes/execute.py +121 -0
  14. nexaql/api/routes/ontology.py +23 -0
  15. nexaql/api/routes/suggest.py +58 -0
  16. nexaql/api/routes/validate.py +101 -0
  17. nexaql/chat/__init__.py +2 -0
  18. nexaql/chat/agent.py +292 -0
  19. nexaql/chat/prompts.py +244 -0
  20. nexaql/cli.py +293 -0
  21. nexaql/config.py +113 -0
  22. nexaql/data/__init__.py +0 -0
  23. nexaql/data/sample_ecommerce.yaml +282 -0
  24. nexaql/data/sample_ecommerce_seed.sql +203 -0
  25. nexaql/engine/__init__.py +0 -0
  26. nexaql/engine/dialect.py +662 -0
  27. nexaql/engine/lexer.py +180 -0
  28. nexaql/engine/parser.py +537 -0
  29. nexaql/engine/system_functions.py +307 -0
  30. nexaql/engine/translator.py +624 -0
  31. nexaql/engine/types.py +221 -0
  32. nexaql/engine/validator.py +379 -0
  33. nexaql/ontology/__init__.py +28 -0
  34. nexaql/ontology/loader.py +59 -0
  35. nexaql/ontology/models.py +162 -0
  36. nexaql/ontology/prompt.py +222 -0
  37. nexaql/policy/__init__.py +16 -0
  38. nexaql/policy/context.py +33 -0
  39. nexaql/policy/enforcer.py +254 -0
  40. nexaql/policy/masking.py +68 -0
  41. nexaql/static/assets/QueryEditor-DvovKcLh.js +86 -0
  42. nexaql/static/assets/index-BjMmBuvn.js +82 -0
  43. nexaql/static/assets/index-CP8BA9Z6.css +1 -0
  44. nexaql/static/index.html +13 -0
  45. nexaql-0.1.1.dist-info/METADATA +382 -0
  46. nexaql-0.1.1.dist-info/RECORD +49 -0
  47. nexaql-0.1.1.dist-info/WHEEL +4 -0
  48. nexaql-0.1.1.dist-info/entry_points.txt +2 -0
  49. nexaql-0.1.1.dist-info/licenses/LICENSE +189 -0
nexaql/__init__.py ADDED
File without changes
nexaql/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2026-present NexaQL Contributors
2
+ """Allow running nexaql as ``python -m nexaql``."""
3
+
4
+ from nexaql.cli import main
5
+
6
+ main()
@@ -0,0 +1,90 @@
1
+ # Copyright (c) 2026-present NexaQL Contributors
2
+ """Adapter registry — maps datasource configs to concrete QueryAdapter instances."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Dict, Type
7
+
8
+ from nexaql.adapters.base import AdapterResult, QueryAdapter
9
+
10
+ # Lazy imports to avoid pulling in heavy optional dependencies (asyncpg, duckdb)
11
+ # at module-load time.
12
+
13
+ _ADAPTER_REGISTRY: Dict[str, str] = {
14
+ "postgresql": "nexaql.adapters.postgresql.PostgreSQLAdapter",
15
+ "duckdb": "nexaql.adapters.duckdb_adapter.DuckDBAdapter",
16
+ }
17
+
18
+
19
+ def _import_class(dotted_path: str) -> Type[QueryAdapter]:
20
+ """Import a class from a fully qualified dotted path."""
21
+ module_path, class_name = dotted_path.rsplit(".", 1)
22
+ import importlib
23
+
24
+ module = importlib.import_module(module_path)
25
+ return getattr(module, class_name)
26
+
27
+
28
+ def get_adapter(datasource_config: Any) -> QueryAdapter:
29
+ """Instantiate the appropriate :class:`QueryAdapter` for *datasource_config*.
30
+
31
+ Parameters
32
+ ----------
33
+ datasource_config:
34
+ A datasource configuration object (e.g. from ``nexaql.yaml`` or the
35
+ ontology models). Must expose a ``type`` attribute (``str``) and may
36
+ carry adapter-specific fields such as ``url`` or ``path``.
37
+
38
+ Returns
39
+ -------
40
+ QueryAdapter
41
+ A ready-to-use adapter instance.
42
+
43
+ Raises
44
+ ------
45
+ ValueError
46
+ If the datasource type is not supported.
47
+ """
48
+ ds_type: str = getattr(datasource_config, "type", None) or (
49
+ datasource_config.get("type") if isinstance(datasource_config, dict) else None
50
+ )
51
+
52
+ if ds_type is None:
53
+ raise ValueError("Datasource config must have a 'type' field")
54
+
55
+ dotted_path = _ADAPTER_REGISTRY.get(ds_type)
56
+ if dotted_path is None:
57
+ raise ValueError(
58
+ f"Unsupported datasource type '{ds_type}'. "
59
+ f"Supported types: {', '.join(sorted(_ADAPTER_REGISTRY))}"
60
+ )
61
+
62
+ adapter_cls = _import_class(dotted_path)
63
+
64
+ # Build constructor kwargs from the config
65
+ if ds_type == "postgresql":
66
+ url = getattr(datasource_config, "url", None) or (
67
+ datasource_config.get("url") if isinstance(datasource_config, dict) else None
68
+ )
69
+ if not url:
70
+ raise ValueError("PostgreSQL datasource requires a 'url' field")
71
+ return adapter_cls(connection_url=url)
72
+
73
+ if ds_type == "duckdb":
74
+ path = getattr(datasource_config, "path", None) or (
75
+ datasource_config.get("path") if isinstance(datasource_config, dict) else None
76
+ )
77
+ seed_file = getattr(datasource_config, "seed_file", None) or (
78
+ datasource_config.get("seed_file") if isinstance(datasource_config, dict) else None
79
+ )
80
+ return adapter_cls(path=path or ":memory:", seed_file=seed_file)
81
+
82
+ # Generic fallback (should not be reached given the registry check above)
83
+ return adapter_cls()
84
+
85
+
86
+ __all__ = [
87
+ "AdapterResult",
88
+ "QueryAdapter",
89
+ "get_adapter",
90
+ ]
@@ -0,0 +1,47 @@
1
+ # Copyright (c) 2026-present NexaQL Contributors
2
+ """Abstract base class and shared types for database adapters."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from nexaql.engine.types import ColumnMeta, NodeShape, QueryAST
11
+
12
+
13
+ @dataclass
14
+ class AdapterResult:
15
+ """Result returned by a QueryAdapter after executing a query."""
16
+
17
+ rows: List[Dict[str, Any]]
18
+ columns: List[ColumnMeta]
19
+ row_count: int
20
+ shape: NodeShape
21
+ query_preview: str
22
+ adapter_type: str
23
+
24
+
25
+ class QueryAdapter(ABC):
26
+ """Interface that every database adapter must implement."""
27
+
28
+ @property
29
+ @abstractmethod
30
+ def adapter_type(self) -> str:
31
+ """Short identifier for this adapter (e.g. ``'postgresql'``, ``'duckdb'``)."""
32
+ ...
33
+
34
+ @abstractmethod
35
+ async def execute(self, ast: QueryAST, ontology: Any) -> AdapterResult:
36
+ """Translate *ast* to SQL using *ontology*, run it, and return the result."""
37
+ ...
38
+
39
+ @abstractmethod
40
+ def describe(self, ast: QueryAST, ontology: Any) -> str:
41
+ """Translate *ast* to SQL and return the SQL string without executing."""
42
+ ...
43
+
44
+ @abstractmethod
45
+ async def healthcheck(self) -> bool:
46
+ """Return ``True`` if the underlying datasource is reachable."""
47
+ ...
@@ -0,0 +1,138 @@
1
+ """DuckDB adapter — executes NexaQL queries against a DuckDB database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from typing import Any, Dict, List
8
+
9
+ import duckdb
10
+
11
+ from nexaql.adapters.base import AdapterResult, QueryAdapter
12
+ from nexaql.engine.translator import translate
13
+ from nexaql.engine.types import ColumnMeta, QueryAST
14
+
15
+ # DuckDB Python type name -> friendly type name
16
+ _DUCKDB_TYPE_MAP: Dict[str, str] = {
17
+ "BOOLEAN": "boolean",
18
+ "TINYINT": "integer",
19
+ "SMALLINT": "integer",
20
+ "INTEGER": "integer",
21
+ "BIGINT": "integer",
22
+ "HUGEINT": "integer",
23
+ "FLOAT": "numeric",
24
+ "DOUBLE": "numeric",
25
+ "DECIMAL": "numeric",
26
+ "VARCHAR": "string",
27
+ "DATE": "date",
28
+ "TIMESTAMP": "date",
29
+ "TIMESTAMP WITH TIME ZONE": "date",
30
+ }
31
+
32
+
33
+ def _duckdb_type_to_str(type_name: Any) -> str:
34
+ # DuckDB returns DuckDBPyType objects, not plain strings — convert first
35
+ name = str(type_name).upper()
36
+ return _DUCKDB_TYPE_MAP.get(name, "string")
37
+
38
+
39
+ class DuckDBAdapter(QueryAdapter):
40
+ """In-process adapter for DuckDB.
41
+
42
+ Uses a persistent connection so :memory: databases retain data across queries.
43
+ Supports auto-seeding via a SQL file on first connect.
44
+ """
45
+
46
+ def __init__(self, path: str = ":memory:", seed_file: str | None = None) -> None:
47
+ self._path = path
48
+ self._seed_file = seed_file
49
+ self._conn: duckdb.DuckDBPyConnection | None = None
50
+
51
+ def _get_conn(self) -> duckdb.DuckDBPyConnection:
52
+ if self._conn is None:
53
+ self._conn = duckdb.connect(self._path)
54
+ self._maybe_seed()
55
+ return self._conn
56
+
57
+ def _maybe_seed(self) -> None:
58
+ """Run the seed SQL file if the database has no tables."""
59
+ if not self._seed_file or not self._conn:
60
+ return
61
+
62
+ # Check if tables already exist
63
+ tables = self._conn.execute(
64
+ "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'main'"
65
+ ).fetchone()
66
+ if tables and tables[0] > 0:
67
+ return
68
+
69
+ # Resolve seed file path relative to config file location
70
+ seed_path = self._seed_file
71
+ if not os.path.isabs(seed_path):
72
+ # Try relative to CWD
73
+ if not os.path.exists(seed_path):
74
+ return
75
+
76
+ if os.path.exists(seed_path):
77
+ with open(seed_path) as f:
78
+ sql = f.read()
79
+ self._conn.execute(sql)
80
+ table_count = self._conn.execute(
81
+ "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'main'"
82
+ ).fetchone()
83
+ print(f" Seeded DuckDB with {table_count[0] if table_count else 0} tables from {seed_path}")
84
+
85
+ # -- QueryAdapter interface ------------------------------------------------
86
+
87
+ @property
88
+ def adapter_type(self) -> str:
89
+ return "duckdb"
90
+
91
+ def describe(self, ast: QueryAST, ontology: Any) -> str:
92
+ result = translate(ast, ontology)
93
+ return result.sql
94
+
95
+ async def execute(self, ast: QueryAST, ontology: Any) -> AdapterResult:
96
+ result = translate(ast, ontology)
97
+ sql = result.sql
98
+
99
+ loop = asyncio.get_running_loop()
100
+ rows, columns = await loop.run_in_executor(None, self._run_query, sql)
101
+
102
+ return AdapterResult(
103
+ rows=rows,
104
+ columns=columns,
105
+ row_count=len(rows),
106
+ shape=result.shape,
107
+ query_preview=sql,
108
+ adapter_type=self.adapter_type,
109
+ )
110
+
111
+ async def healthcheck(self) -> bool:
112
+ try:
113
+ loop = asyncio.get_running_loop()
114
+ await loop.run_in_executor(None, self._ping)
115
+ return True
116
+ except Exception:
117
+ return False
118
+
119
+ # -- internal helpers ------------------------------------------------------
120
+
121
+ def _run_query(self, sql: str) -> tuple[List[Dict[str, Any]], List[ColumnMeta]]:
122
+ conn = self._get_conn()
123
+ rel = conn.execute(sql)
124
+ description = rel.description
125
+ raw_rows = rel.fetchall()
126
+
127
+ columns: List[ColumnMeta] = []
128
+ col_names: List[str] = []
129
+ for col_name, col_type, *_ in description:
130
+ columns.append(ColumnMeta(name=col_name, type=_duckdb_type_to_str(col_type)))
131
+ col_names.append(col_name)
132
+
133
+ rows: List[Dict[str, Any]] = [dict(zip(col_names, row)) for row in raw_rows]
134
+ return rows, columns
135
+
136
+ def _ping(self) -> None:
137
+ conn = self._get_conn()
138
+ conn.execute("SELECT 1").fetchone()
@@ -0,0 +1,106 @@
1
+ # Copyright (c) 2026-present NexaQL Contributors
2
+ """PostgreSQL adapter — executes NexaQL queries against a PostgreSQL database."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import asyncpg
10
+
11
+ from nexaql.adapters.base import AdapterResult, QueryAdapter
12
+ from nexaql.engine.translator import translate
13
+ from nexaql.engine.types import ColumnMeta, QueryAST
14
+
15
+ # asyncpg OID -> friendly type name (covers the most common types)
16
+ _PG_TYPE_MAP: Dict[int, str] = {
17
+ 16: "boolean",
18
+ 20: "integer",
19
+ 21: "integer",
20
+ 23: "integer",
21
+ 25: "string",
22
+ 700: "numeric",
23
+ 701: "numeric",
24
+ 1042: "string",
25
+ 1043: "string",
26
+ 1082: "date",
27
+ 1114: "date",
28
+ 1184: "date",
29
+ 1700: "numeric",
30
+ }
31
+
32
+
33
+ def _pg_oid_to_type(oid: int) -> str:
34
+ return _PG_TYPE_MAP.get(oid, "string")
35
+
36
+
37
+ class PostgreSQLAdapter(QueryAdapter):
38
+ """Async adapter backed by an ``asyncpg`` connection pool."""
39
+
40
+ def __init__(self, connection_url: str) -> None:
41
+ self._url = connection_url
42
+ self._pool: Optional[asyncpg.Pool] = None
43
+ self._lock = asyncio.Lock()
44
+
45
+ # -- pool management -------------------------------------------------------
46
+
47
+ async def _get_pool(self) -> asyncpg.Pool:
48
+ if self._pool is None or self._pool._closed:
49
+ async with self._lock:
50
+ if self._pool is None or self._pool._closed:
51
+ self._pool = await asyncpg.create_pool(self._url, min_size=1, max_size=5)
52
+ return self._pool
53
+
54
+ async def close(self) -> None:
55
+ if self._pool is not None and not self._pool._closed:
56
+ await self._pool.close()
57
+ self._pool = None
58
+
59
+ # -- QueryAdapter interface ------------------------------------------------
60
+
61
+ @property
62
+ def adapter_type(self) -> str:
63
+ return "postgresql"
64
+
65
+ def describe(self, ast: QueryAST, ontology: Any) -> str:
66
+ result = translate(ast, ontology)
67
+ return result.sql
68
+
69
+ async def execute(self, ast: QueryAST, ontology: Any) -> AdapterResult:
70
+ result = translate(ast, ontology)
71
+ sql = result.sql
72
+
73
+ pool = await self._get_pool()
74
+ async with pool.acquire() as conn:
75
+ stmt = await conn.prepare(sql)
76
+ records = await stmt.fetch()
77
+
78
+ # Infer column metadata from the prepared statement attributes
79
+ columns: List[ColumnMeta] = []
80
+ for attr in stmt.get_attributes():
81
+ columns.append(
82
+ ColumnMeta(
83
+ name=attr.name,
84
+ type=_pg_oid_to_type(attr.type.oid),
85
+ )
86
+ )
87
+
88
+ rows: List[Dict[str, Any]] = [dict(r) for r in records]
89
+
90
+ return AdapterResult(
91
+ rows=rows,
92
+ columns=columns,
93
+ row_count=len(rows),
94
+ shape=result.shape,
95
+ query_preview=sql,
96
+ adapter_type=self.adapter_type,
97
+ )
98
+
99
+ async def healthcheck(self) -> bool:
100
+ try:
101
+ pool = await self._get_pool()
102
+ async with pool.acquire() as conn:
103
+ await conn.fetchval("SELECT 1")
104
+ return True
105
+ except Exception:
106
+ return False
nexaql/api/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026-present NexaQL Contributors
2
+ """NexaQL FastAPI application package."""
nexaql/api/app.py ADDED
@@ -0,0 +1,58 @@
1
+ # Copyright (c) 2026-present NexaQL Contributors
2
+ """FastAPI application factory for NexaQL."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import JSONResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+
14
+ from nexaql.api.deps import get_config
15
+ from nexaql.api.routes import chat, execute, ontology, suggest, validate
16
+
17
+
18
+ def create_app() -> FastAPI:
19
+ """Build and return the FastAPI application instance."""
20
+ cfg = get_config()
21
+
22
+ app = FastAPI(
23
+ title="NexaQL",
24
+ description="A GraphQL-inspired query language for any structured data",
25
+ version="0.1.0",
26
+ )
27
+
28
+ # ── CORS ────────────────────────────────────────────────────────────────
29
+ app.add_middleware(
30
+ CORSMiddleware,
31
+ allow_origins=cfg.server.cors_origins,
32
+ allow_credentials=True,
33
+ allow_methods=["*"],
34
+ allow_headers=["*"],
35
+ )
36
+
37
+ # ── API routes ──────────────────────────────────────────────────────────
38
+ app.include_router(execute.router, prefix="/api")
39
+ app.include_router(validate.router, prefix="/api")
40
+ app.include_router(ontology.router, prefix="/api")
41
+ app.include_router(suggest.router, prefix="/api")
42
+ app.include_router(chat.router, prefix="/api")
43
+
44
+ # ── Health check ────────────────────────────────────────────────────────
45
+ @app.get("/api/health")
46
+ async def health() -> JSONResponse:
47
+ return JSONResponse({"status": "ok"})
48
+
49
+ # ── Serve frontend static files ───────────────────────────────────────
50
+ # Check bundled static dir first (pip install), then local frontend/dist (dev)
51
+ bundled_static = Path(__file__).parent.parent / "static"
52
+ local_dist = Path(os.getcwd()) / "frontend" / "dist"
53
+
54
+ static_dir = bundled_static if bundled_static.is_dir() else local_dist
55
+ if static_dir.is_dir():
56
+ app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="frontend")
57
+
58
+ return app
nexaql/api/deps.py ADDED
@@ -0,0 +1,75 @@
1
+ # Copyright (c) 2026-present NexaQL Contributors
2
+ """FastAPI dependencies for NexaQL API routes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from functools import lru_cache
8
+ from typing import Any
9
+
10
+ from nexaql.adapters import get_adapter as _get_adapter
11
+ from nexaql.adapters.base import QueryAdapter
12
+ from nexaql.config import NexaQLConfig, load_config
13
+ from nexaql.ontology import Ontology, load_ontology
14
+
15
+
16
+ # ── Cached config loader ────────────────────────────────────────────────────
17
+
18
+
19
+ @lru_cache(maxsize=1)
20
+ def get_config() -> NexaQLConfig:
21
+ """Load and cache the ``nexaql.yaml`` configuration.
22
+
23
+ The config path is resolved from the ``NEXAQL_CONFIG`` environment
24
+ variable, falling back to ``nexaql.yaml`` in the current directory.
25
+ """
26
+ path = os.environ.get("NEXAQL_CONFIG", "nexaql.yaml")
27
+ return load_config(path)
28
+
29
+
30
+ # ── Cached ontology loader ──────────────────────────────────────────────────
31
+
32
+
33
+ def get_ontology() -> Ontology:
34
+ """Load the ontology specified in the config.
35
+
36
+ Uses the file-mtime cache built into :func:`nexaql.ontology.load_ontology`
37
+ so the ontology is automatically reloaded when the YAML file changes.
38
+ """
39
+ cfg = get_config()
40
+ return load_ontology(cfg.ontology.path)
41
+
42
+
43
+ # ── Adapter factory ─────────────────────────────────────────────────────────
44
+
45
+
46
+ _adapter_cache: dict[str, QueryAdapter] = {}
47
+
48
+
49
+ def get_adapter_for_datasource(datasource_name: str | None = None) -> QueryAdapter:
50
+ """Get or create a :class:`QueryAdapter` from the config's datasource entry.
51
+
52
+ Adapters are cached so in-memory DuckDB databases retain seeded data.
53
+ If *datasource_name* is ``None`` the first datasource in the config is used.
54
+ """
55
+ cfg = get_config()
56
+
57
+ if not cfg.datasources:
58
+ raise ValueError("No datasources configured in nexaql.yaml")
59
+
60
+ if datasource_name is None:
61
+ datasource_name = next(iter(cfg.datasources))
62
+
63
+ if datasource_name in _adapter_cache:
64
+ return _adapter_cache[datasource_name]
65
+
66
+ ds_entry = cfg.datasources.get(datasource_name)
67
+ if ds_entry is None:
68
+ available = ", ".join(cfg.datasources.keys())
69
+ raise ValueError(
70
+ f"Datasource '{datasource_name}' not found. Available: {available}"
71
+ )
72
+
73
+ adapter = _get_adapter(ds_entry)
74
+ _adapter_cache[datasource_name] = adapter
75
+ return adapter
@@ -0,0 +1,44 @@
1
+ # Copyright (c) 2026-present NexaQL Contributors
2
+ """FastAPI dependency that extracts UserContext from the request."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+
9
+ from fastapi import Request
10
+
11
+ from nexaql.policy.context import ANONYMOUS, UserContext
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ async def get_user_context(request: Request) -> UserContext:
17
+ """Extract a :class:`UserContext` from the incoming request.
18
+
19
+ Dev mode (default):
20
+ - Check the ``X-User-Context`` header. If present, parse it as JSON
21
+ into a :class:`UserContext`.
22
+ - If the header is absent, return :data:`ANONYMOUS` (full access,
23
+ ``roles=["*"]``).
24
+
25
+ Malformed JSON is handled gracefully: a warning is logged and
26
+ :data:`ANONYMOUS` is returned so the request is not blocked by a
27
+ bad header.
28
+ """
29
+ header_value = request.headers.get("X-User-Context")
30
+ if not header_value:
31
+ return ANONYMOUS
32
+
33
+ try:
34
+ data = json.loads(header_value)
35
+ return UserContext(
36
+ user_id=data.get("user_id", "anonymous"),
37
+ roles=data.get("roles", []),
38
+ attributes=data.get("attributes", {}),
39
+ )
40
+ except (json.JSONDecodeError, TypeError, KeyError) as exc:
41
+ logger.warning(
42
+ "Malformed X-User-Context header, falling back to ANONYMOUS: %s", exc
43
+ )
44
+ return ANONYMOUS
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026-present NexaQL Contributors
2
+ """NexaQL API route modules."""
@@ -0,0 +1,92 @@
1
+ # Copyright (c) 2026-present NexaQL Contributors
2
+ """POST /api/chat -- NL -> NexaQL -> execute -> summarize pipeline."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Optional
7
+
8
+ from fastapi import APIRouter, HTTPException
9
+ from pydantic import BaseModel
10
+
11
+ from nexaql.adapters import get_adapter
12
+ from nexaql.api.deps import get_config, get_ontology
13
+ from nexaql.chat.agent import ChatResponse, ask
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ class ChatMessage(BaseModel):
19
+ role: str # "user" | "assistant"
20
+ content: str
21
+
22
+
23
+ class ChatRequest(BaseModel):
24
+ question: str
25
+ history: list[ChatMessage] = []
26
+
27
+
28
+ class ChatResponseBody(BaseModel):
29
+ explanation: str | None = None
30
+ nexaqlQuery: str | None = None
31
+ queryPreview: str | None = None
32
+ adapterType: str | None = None
33
+ rows: list[dict[str, Any]] = []
34
+ columns: list[dict[str, str]] = []
35
+ rowCount: int = 0
36
+ shape: Any | None = None
37
+ summary: str | None = None
38
+ error: str | None = None
39
+
40
+
41
+ @router.post("/chat")
42
+ async def chat_endpoint(body: ChatRequest) -> ChatResponseBody:
43
+ if not body.question.strip():
44
+ raise HTTPException(status_code=400, detail="Question is required")
45
+
46
+ cfg = get_config()
47
+
48
+ # Check LLM API key before doing anything
49
+ if not cfg.llm.api_key:
50
+ return ChatResponseBody(
51
+ error=(
52
+ "Agent Chat requires an LLM API key. "
53
+ "Set ANTHROPIC_API_KEY in your environment, or add it to nexaql.yaml under llm.api_key."
54
+ ),
55
+ )
56
+
57
+ ontology = get_ontology()
58
+
59
+ # Resolve the default adapter
60
+ try:
61
+ if cfg.datasources:
62
+ adapter = get_adapter(next(iter(cfg.datasources.values())))
63
+ else:
64
+ adapter = None
65
+ except Exception:
66
+ adapter = None
67
+
68
+ history = [{"role": m.role, "content": m.content} for m in body.history]
69
+
70
+ try:
71
+ result: ChatResponse = await ask(
72
+ question=body.question,
73
+ history=history,
74
+ ontology=ontology,
75
+ adapter=adapter,
76
+ llm_config=cfg.llm,
77
+ )
78
+ except Exception as e:
79
+ return ChatResponseBody(error=str(e))
80
+
81
+ return ChatResponseBody(
82
+ explanation=result.explanation,
83
+ nexaqlQuery=result.nexaql_query,
84
+ queryPreview=result.query_preview,
85
+ adapterType=result.adapter_type,
86
+ rows=result.rows,
87
+ columns=[{"name": c.name, "type": c.type} for c in result.columns] if result.columns else [],
88
+ rowCount=result.row_count,
89
+ shape=result.shape,
90
+ summary=result.summary,
91
+ error=result.error,
92
+ )