nl2sql-api 0.1.0__tar.gz
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.
- nl2sql_api-0.1.0/PKG-INFO +11 -0
- nl2sql_api-0.1.0/README.md +85 -0
- nl2sql_api-0.1.0/pyproject.toml +24 -0
- nl2sql_api-0.1.0/setup.cfg +4 -0
- nl2sql_api-0.1.0/src/nl2sql_api/__init__.py +0 -0
- nl2sql_api-0.1.0/src/nl2sql_api/dependencies.py +44 -0
- nl2sql_api-0.1.0/src/nl2sql_api/main.py +47 -0
- nl2sql_api-0.1.0/src/nl2sql_api/models/__init__.py +0 -0
- nl2sql_api-0.1.0/src/nl2sql_api/models/datasource.py +11 -0
- nl2sql_api-0.1.0/src/nl2sql_api/models/llm.py +11 -0
- nl2sql_api-0.1.0/src/nl2sql_api/models/query.py +33 -0
- nl2sql_api-0.1.0/src/nl2sql_api/models/response.py +13 -0
- nl2sql_api-0.1.0/src/nl2sql_api/models/schema.py +8 -0
- nl2sql_api-0.1.0/src/nl2sql_api/routes/__init__.py +0 -0
- nl2sql_api-0.1.0/src/nl2sql_api/routes/datasource.py +57 -0
- nl2sql_api-0.1.0/src/nl2sql_api/routes/health.py +22 -0
- nl2sql_api-0.1.0/src/nl2sql_api/routes/indexing.py +73 -0
- nl2sql_api-0.1.0/src/nl2sql_api/routes/llm.py +41 -0
- nl2sql_api-0.1.0/src/nl2sql_api/routes/query.py +29 -0
- nl2sql_api-0.1.0/src/nl2sql_api/server.py +30 -0
- nl2sql_api-0.1.0/src/nl2sql_api/services/__init__.py +15 -0
- nl2sql_api-0.1.0/src/nl2sql_api/services/datasource.py +43 -0
- nl2sql_api-0.1.0/src/nl2sql_api/services/health.py +22 -0
- nl2sql_api-0.1.0/src/nl2sql_api/services/indexing.py +30 -0
- nl2sql_api-0.1.0/src/nl2sql_api/services/llm.py +25 -0
- nl2sql_api-0.1.0/src/nl2sql_api/services/query.py +34 -0
- nl2sql_api-0.1.0/src/nl2sql_api.egg-info/PKG-INFO +11 -0
- nl2sql_api-0.1.0/src/nl2sql_api.egg-info/SOURCES.txt +34 -0
- nl2sql_api-0.1.0/src/nl2sql_api.egg-info/dependency_links.txt +1 -0
- nl2sql_api-0.1.0/src/nl2sql_api.egg-info/entry_points.txt +2 -0
- nl2sql_api-0.1.0/src/nl2sql_api.egg-info/requires.txt +6 -0
- nl2sql_api-0.1.0/src/nl2sql_api.egg-info/top_level.txt +1 -0
- nl2sql_api-0.1.0/tests/test_app.py +32 -0
- nl2sql_api-0.1.0/tests/test_cors.py +72 -0
- nl2sql_api-0.1.0/tests/test_query_routes.py +125 -0
- nl2sql_api-0.1.0/tests/test_services.py +55 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nl2sql-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: API layer for NL2SQL engine
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: nl2sql-engine~=0.1
|
|
7
|
+
Requires-Dist: fastapi>=0.100.0
|
|
8
|
+
Requires-Dist: uvicorn>=0.20.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Requires-Dist: python-multipart>=0.0.6
|
|
11
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# NL2SQL API
|
|
2
|
+
|
|
3
|
+
API layer for the NL2SQL engine that provides a REST interface to the core functionality.
|
|
4
|
+
|
|
5
|
+
## Two-Tier API Architecture
|
|
6
|
+
|
|
7
|
+
NL2SQL provides a two-tier API architecture:
|
|
8
|
+
|
|
9
|
+
### 1. Core API (Python)
|
|
10
|
+
- **Location**: Core package (`nl2sql`)
|
|
11
|
+
- **Interface**: Direct Python class interface (`NL2SQL` class)
|
|
12
|
+
- **Use Case**: Direct Python integration, embedded applications
|
|
13
|
+
- **Access**: Import and use directly in Python code
|
|
14
|
+
|
|
15
|
+
### 2. REST API (HTTP) - This Package
|
|
16
|
+
- **Location**: API package (`nl2sql-api`) - This package
|
|
17
|
+
- **Interface**: HTTP REST endpoints
|
|
18
|
+
- **Use Case**: Remote clients, web applications, TypeScript CLI
|
|
19
|
+
- **Access**: HTTP requests to API endpoints
|
|
20
|
+
|
|
21
|
+
This REST API package serves as a bridge between external HTTP clients and the core NL2SQL engine, using the core's public API internally.
|
|
22
|
+
|
|
23
|
+
## Overview
|
|
24
|
+
|
|
25
|
+
This package provides a FastAPI-based REST API that uses the NL2SQL core's public API to interact with the NL2SQL engine over HTTP. It serves as a bridge between external clients (such as the TypeScript CLI) and the core engine functionality.
|
|
26
|
+
|
|
27
|
+
## Architecture
|
|
28
|
+
|
|
29
|
+
The API package leverages the NL2SQL core's public API layer (`NL2SQL` class), ensuring clean separation between the API service and the core engine implementation. The service layer uses the core's public methods like `run_query()`, `list_datasources()`, and schema access through `engine.context.schema_store`.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- RESTful API endpoints for natural language to SQL conversion
|
|
34
|
+
- Datasource and LLM management endpoints
|
|
35
|
+
- Health and readiness checks
|
|
36
|
+
- Proper error handling and response formatting
|
|
37
|
+
- Lazy initialization to avoid configuration issues during import
|
|
38
|
+
- Integration with the core's public API layer
|
|
39
|
+
|
|
40
|
+
## Endpoints
|
|
41
|
+
|
|
42
|
+
### Query Endpoints
|
|
43
|
+
- `POST /api/v1/query` - Execute a natural language query
|
|
44
|
+
- `GET /api/v1/health` - Health check endpoint
|
|
45
|
+
- `GET /api/v1/ready` - Readiness check endpoint
|
|
46
|
+
|
|
47
|
+
### Datasource Management Endpoints
|
|
48
|
+
- `POST /api/v1/datasource` - Add a new datasource programmatically
|
|
49
|
+
- `GET /api/v1/datasource` - List all registered datasources
|
|
50
|
+
- `GET /api/v1/datasource/{datasource_id}` - Get details of a specific datasource
|
|
51
|
+
- `DELETE /api/v1/datasource/{datasource_id}` - Remove a datasource (not currently supported)
|
|
52
|
+
|
|
53
|
+
### LLM Management Endpoints
|
|
54
|
+
- `POST /api/v1/llm` - Configure an LLM programmatically
|
|
55
|
+
- `GET /api/v1/llm` - List all configured LLMs
|
|
56
|
+
- `GET /api/v1/llm/{llm_name}` - Get details of a specific LLM
|
|
57
|
+
|
|
58
|
+
### Indexing Management Endpoints
|
|
59
|
+
- `POST /api/v1/index/{datasource_id}` - Index schema for a specific datasource
|
|
60
|
+
- `POST /api/v1/index-all` - Index schema for all registered datasources
|
|
61
|
+
- `DELETE /api/v1/index` - Clear the vector store index
|
|
62
|
+
- `GET /api/v1/index/status` - Get the status of the index
|
|
63
|
+
|
|
64
|
+
## Running the API
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install -e .
|
|
68
|
+
nl2sql-api --host 0.0.0.0 --port 8000 --reload
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or using the server script directly:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python -m nl2sql_api.server --host 0.0.0.0 --port 8000 --reload
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
Install in development mode:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install -e .
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
For detailed API documentation, see [API_DOCS.md](API_DOCS.md).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nl2sql-api"
|
|
7
|
+
version = "0.1.0" # x-release-please-version
|
|
8
|
+
description = "API layer for NL2SQL engine"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"nl2sql-engine~=0.1",
|
|
12
|
+
"fastapi>=0.100.0",
|
|
13
|
+
"uvicorn>=0.20.0",
|
|
14
|
+
"pydantic>=2.0",
|
|
15
|
+
"python-multipart>=0.0.6",
|
|
16
|
+
"python-dotenv>=1.0.0"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
nl2sql-api = "nl2sql_api.server:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
include = ["nl2sql_api*"]
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from fastapi import Depends, Request
|
|
2
|
+
|
|
3
|
+
from nl2sql import NL2SQL
|
|
4
|
+
from nl2sql_api.services import (
|
|
5
|
+
DatasourceService,
|
|
6
|
+
QueryService,
|
|
7
|
+
LLMService,
|
|
8
|
+
IndexingService,
|
|
9
|
+
HealthService,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_engine(request: Request) -> NL2SQL:
|
|
14
|
+
return request.app.state.engine
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_datasource_service(
|
|
18
|
+
engine: NL2SQL = Depends(get_engine),
|
|
19
|
+
) -> DatasourceService:
|
|
20
|
+
return DatasourceService(engine)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_query_service(
|
|
24
|
+
engine: NL2SQL = Depends(get_engine),
|
|
25
|
+
) -> QueryService:
|
|
26
|
+
return QueryService(engine)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_llm_service(
|
|
30
|
+
engine: NL2SQL = Depends(get_engine),
|
|
31
|
+
) -> LLMService:
|
|
32
|
+
return LLMService(engine)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_indexing_service(
|
|
36
|
+
engine: NL2SQL = Depends(get_engine),
|
|
37
|
+
) -> IndexingService:
|
|
38
|
+
return IndexingService(engine)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_health_service(
|
|
42
|
+
engine: NL2SQL = Depends(get_engine),
|
|
43
|
+
) -> HealthService:
|
|
44
|
+
return HealthService(engine)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
|
|
6
|
+
from .routes import query, health, datasource, llm, indexing
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
from nl2sql import NL2SQL
|
|
9
|
+
from nl2sql.common.logger import configure_logging
|
|
10
|
+
from nl2sql.common.settings import settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@asynccontextmanager
|
|
14
|
+
async def lifespan(app: FastAPI):
|
|
15
|
+
# The library no longer configures logging on import, so the application
|
|
16
|
+
# entry point owns it - before anything that logs is constructed.
|
|
17
|
+
configure_logging(
|
|
18
|
+
level="INFO",
|
|
19
|
+
json_format=(settings.observability_exporter == "otlp"),
|
|
20
|
+
)
|
|
21
|
+
app.state.engine = NL2SQL()
|
|
22
|
+
yield
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
app = FastAPI(
|
|
26
|
+
title="NL2SQL API",
|
|
27
|
+
version="0.1.0",
|
|
28
|
+
lifespan=lifespan,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
app.include_router(query.router, prefix="/api/v1")
|
|
32
|
+
app.include_router(health.router, prefix="/api/v1")
|
|
33
|
+
app.include_router(datasource.router, prefix="/api/v1")
|
|
34
|
+
app.include_router(llm.router, prefix="/api/v1")
|
|
35
|
+
app.include_router(indexing.router, prefix="/api/v1")
|
|
36
|
+
|
|
37
|
+
_origins = [o.strip() for o in os.getenv("NL2SQL_API_CORS_ORIGINS", "").split(",") if o.strip()]
|
|
38
|
+
|
|
39
|
+
app.add_middleware(
|
|
40
|
+
CORSMiddleware,
|
|
41
|
+
allow_origins=_origins,
|
|
42
|
+
allow_credentials=bool(_origins),
|
|
43
|
+
allow_methods=["*"],
|
|
44
|
+
allow_headers=["*"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Optional, Dict, Any, List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class QueryRequest(BaseModel):
|
|
6
|
+
natural_language: str
|
|
7
|
+
datasource_id: Optional[str] = None
|
|
8
|
+
execute: bool = True
|
|
9
|
+
user_context: Optional[Dict[str, Any]] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SubQueryResponse(BaseModel):
|
|
13
|
+
"""One decomposed sub-query and the SQL generated for it."""
|
|
14
|
+
id: str = ""
|
|
15
|
+
intent: str = ""
|
|
16
|
+
sql: str = ""
|
|
17
|
+
datasource_id: str = ""
|
|
18
|
+
schema_version: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class QueryResponse(BaseModel):
|
|
22
|
+
"""Mirrors ``nl2sql.api.query_api.QueryResult``.
|
|
23
|
+
|
|
24
|
+
Result rows are not inlined; they live in artifact storage and are addressable
|
|
25
|
+
through ``artifact_refs``.
|
|
26
|
+
"""
|
|
27
|
+
sub_queries: List[SubQueryResponse] = Field(default_factory=list)
|
|
28
|
+
final_answer: Optional[Dict[str, Any]] = None
|
|
29
|
+
errors: List[Dict[str, Any]] = Field(default_factory=list)
|
|
30
|
+
trace_id: Optional[str] = None
|
|
31
|
+
reasoning: List[Dict[str, Any]] = Field(default_factory=list)
|
|
32
|
+
warnings: List[Dict[str, Any]] = Field(default_factory=list)
|
|
33
|
+
artifact_refs: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional, List, Dict, Any
|
|
3
|
+
|
|
4
|
+
class ErrorResponse(BaseModel):
|
|
5
|
+
error_code: str
|
|
6
|
+
message: str
|
|
7
|
+
details: Optional[Dict[str, Any]] = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SuccessResponse(BaseModel):
|
|
11
|
+
success: bool
|
|
12
|
+
data: Optional[Dict[str, Any]] = None
|
|
13
|
+
message: Optional[str] = None
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException, Depends
|
|
2
|
+
from typing import Dict, Any, Annotated
|
|
3
|
+
|
|
4
|
+
from nl2sql_api.models.datasource import DatasourceRequest, DatasourceResponse
|
|
5
|
+
from nl2sql_api.dependencies import get_datasource_service
|
|
6
|
+
from nl2sql_api.services import DatasourceService
|
|
7
|
+
|
|
8
|
+
router = APIRouter()
|
|
9
|
+
|
|
10
|
+
DatasourceSvc = Annotated[DatasourceService, Depends(get_datasource_service)]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.post("/datasource", response_model=DatasourceResponse)
|
|
14
|
+
def add_datasource(
|
|
15
|
+
payload: DatasourceRequest,
|
|
16
|
+
service: DatasourceSvc,
|
|
17
|
+
):
|
|
18
|
+
try:
|
|
19
|
+
return service.add_datasource(payload)
|
|
20
|
+
except Exception as e:
|
|
21
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/datasource", response_model=Dict[str, Any])
|
|
25
|
+
def list_datasources(
|
|
26
|
+
service: DatasourceSvc,
|
|
27
|
+
):
|
|
28
|
+
try:
|
|
29
|
+
return {"datasources": service.list_datasources()}
|
|
30
|
+
except Exception as e:
|
|
31
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("/datasource/{datasource_id}", response_model=Dict[str, Any])
|
|
35
|
+
def get_datasource(
|
|
36
|
+
datasource_id: str,
|
|
37
|
+
service: DatasourceSvc,
|
|
38
|
+
):
|
|
39
|
+
try:
|
|
40
|
+
return service.get_datasource(datasource_id)
|
|
41
|
+
except ValueError as e:
|
|
42
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
43
|
+
except Exception as e:
|
|
44
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.delete("/datasource/{datasource_id}", response_model=Dict[str, Any])
|
|
48
|
+
def remove_datasource(
|
|
49
|
+
datasource_id: str,
|
|
50
|
+
service: DatasourceSvc,
|
|
51
|
+
):
|
|
52
|
+
try:
|
|
53
|
+
return service.remove_datasource(datasource_id)
|
|
54
|
+
except ValueError as e:
|
|
55
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
56
|
+
except Exception as e:
|
|
57
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
from nl2sql_api.models.response import SuccessResponse
|
|
4
|
+
from nl2sql_api.dependencies import get_health_service
|
|
5
|
+
from nl2sql_api.services import HealthService
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
|
|
9
|
+
HealthSvc = Annotated[HealthService, Depends(get_health_service)]
|
|
10
|
+
|
|
11
|
+
@router.get("/health", response_model=SuccessResponse)
|
|
12
|
+
async def health_check(
|
|
13
|
+
service: HealthSvc
|
|
14
|
+
):
|
|
15
|
+
return service.health_check()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/ready", response_model=SuccessResponse)
|
|
19
|
+
async def readiness_check(
|
|
20
|
+
service: HealthSvc,
|
|
21
|
+
):
|
|
22
|
+
return service.readiness_check()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException, Depends
|
|
2
|
+
from typing import Dict, Any, Annotated
|
|
3
|
+
|
|
4
|
+
from nl2sql_api.dependencies import get_indexing_service
|
|
5
|
+
from nl2sql_api.services import IndexingService
|
|
6
|
+
router = APIRouter()
|
|
7
|
+
|
|
8
|
+
IndexingSvc = Annotated[IndexingService, Depends(get_indexing_service)]
|
|
9
|
+
|
|
10
|
+
@router.post("/index/{datasource_id}", response_model=Dict[str, Any])
|
|
11
|
+
def index_datasource(
|
|
12
|
+
datasource_id: str,
|
|
13
|
+
service: IndexingSvc
|
|
14
|
+
):
|
|
15
|
+
try:
|
|
16
|
+
result = service.index_datasource(datasource_id)
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
"success": True,
|
|
20
|
+
"datasource_id": datasource_id,
|
|
21
|
+
"indexing_stats": result,
|
|
22
|
+
"message": f"Successfully indexed datasource '{datasource_id}'",
|
|
23
|
+
}
|
|
24
|
+
except Exception as e:
|
|
25
|
+
raise HTTPException(
|
|
26
|
+
status_code=500,
|
|
27
|
+
detail=f"Failed to index datasource '{datasource_id}': {str(e)}",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("/index-all", response_model=Dict[str, Any])
|
|
32
|
+
def index_all_datasources(
|
|
33
|
+
service: IndexingSvc
|
|
34
|
+
):
|
|
35
|
+
try:
|
|
36
|
+
results = service.index_all_datasources()
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"success": True,
|
|
40
|
+
"indexing_results": results,
|
|
41
|
+
"message": "Successfully initiated indexing for all datasources",
|
|
42
|
+
}
|
|
43
|
+
except Exception as e:
|
|
44
|
+
raise HTTPException(
|
|
45
|
+
status_code=500,
|
|
46
|
+
detail=f"Failed to index all datasources: {str(e)}",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.delete("/index", response_model=Dict[str, Any])
|
|
51
|
+
def clear_index(
|
|
52
|
+
service: IndexingSvc
|
|
53
|
+
):
|
|
54
|
+
try:
|
|
55
|
+
return service.clear_index()
|
|
56
|
+
except Exception as e:
|
|
57
|
+
raise HTTPException(
|
|
58
|
+
status_code=500,
|
|
59
|
+
detail=f"Failed to clear index: {str(e)}",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.get("/index/status", response_model=Dict[str, Any])
|
|
64
|
+
def get_index_status(
|
|
65
|
+
service: IndexingSvc
|
|
66
|
+
):
|
|
67
|
+
try:
|
|
68
|
+
return service.get_index_status()
|
|
69
|
+
except Exception as e:
|
|
70
|
+
raise HTTPException(
|
|
71
|
+
status_code=500,
|
|
72
|
+
detail=f"Failed to get index status: {str(e)}",
|
|
73
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException, Depends
|
|
2
|
+
from typing import Dict, Any, Annotated
|
|
3
|
+
|
|
4
|
+
from nl2sql_api.models.llm import LLMRequest, LLMResponse
|
|
5
|
+
from nl2sql_api.dependencies import get_llm_service
|
|
6
|
+
from nl2sql_api.services import LLMService
|
|
7
|
+
|
|
8
|
+
router = APIRouter()
|
|
9
|
+
|
|
10
|
+
LLMSvc = Annotated[LLMService, Depends(get_llm_service)]
|
|
11
|
+
|
|
12
|
+
@router.post("/llm", response_model=LLMResponse)
|
|
13
|
+
def configure_llm(
|
|
14
|
+
payload: LLMRequest,
|
|
15
|
+
service: LLMSvc,
|
|
16
|
+
):
|
|
17
|
+
try:
|
|
18
|
+
return service.configure_llm(payload)
|
|
19
|
+
except Exception as e:
|
|
20
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/llm", response_model=Dict[str, Any])
|
|
24
|
+
def list_llms(
|
|
25
|
+
service: LLMSvc,
|
|
26
|
+
):
|
|
27
|
+
try:
|
|
28
|
+
return {"llms": service.list_llms()}
|
|
29
|
+
except Exception as e:
|
|
30
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/llm/{llm_name}", response_model=Dict[str, Any])
|
|
34
|
+
def get_llm(
|
|
35
|
+
llm_name: str,
|
|
36
|
+
service: LLMSvc,
|
|
37
|
+
):
|
|
38
|
+
try:
|
|
39
|
+
return service.get_llm(llm_name)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Depends
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from nl2sql_api.models.query import QueryRequest, QueryResponse
|
|
6
|
+
from nl2sql_api.dependencies import get_query_service
|
|
7
|
+
from nl2sql_api.services import QueryService
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
QuerySvc = Annotated[QueryService, Depends(get_query_service)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Synchronous on purpose: the pipeline performs blocking LLM and database calls,
|
|
17
|
+
# so Starlette runs this handler in its threadpool instead of on the event loop.
|
|
18
|
+
@router.post("/query", response_model=QueryResponse)
|
|
19
|
+
def execute_query(
|
|
20
|
+
payload: QueryRequest,
|
|
21
|
+
service: QuerySvc,
|
|
22
|
+
):
|
|
23
|
+
try:
|
|
24
|
+
return service.execute_query(payload)
|
|
25
|
+
except Exception:
|
|
26
|
+
# Pipeline failures are reported in QueryResponse.errors with a 200; reaching
|
|
27
|
+
# here means something genuinely unexpected broke.
|
|
28
|
+
logger.exception("Unexpected failure while executing query")
|
|
29
|
+
raise HTTPException(status_code=500, detail="Failed to execute query.")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""
|
|
3
|
+
NL2SQL API Startup Script
|
|
4
|
+
|
|
5
|
+
This script starts the NL2SQL API server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from nl2sql_api.main import app
|
|
11
|
+
import uvicorn
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
parser = argparse.ArgumentParser(description='NL2SQL API Server')
|
|
15
|
+
parser.add_argument('--host', default='127.0.0.1', help='Host to bind to (default: 127.0.0.1)')
|
|
16
|
+
parser.add_argument('--port', type=int, default=8000, help='Port to bind to (default: 8000)')
|
|
17
|
+
parser.add_argument('--reload', action='store_true', help='Enable auto-reload (development)')
|
|
18
|
+
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
|
|
21
|
+
uvicorn.run(
|
|
22
|
+
'nl2sql_api.main:app',
|
|
23
|
+
host=args.host,
|
|
24
|
+
port=args.port,
|
|
25
|
+
reload=args.reload,
|
|
26
|
+
log_level="info"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if __name__ == '__main__':
|
|
30
|
+
main()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
|
|
2
|
+
from .datasource import DatasourceService
|
|
3
|
+
from .llm import LLMService
|
|
4
|
+
from .health import HealthService
|
|
5
|
+
from .query import QueryService
|
|
6
|
+
from .indexing import IndexingService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"DatasourceService",
|
|
11
|
+
"LLMService",
|
|
12
|
+
"HealthService",
|
|
13
|
+
"QueryService",
|
|
14
|
+
"IndexingService"
|
|
15
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
|
|
3
|
+
from nl2sql import NL2SQL
|
|
4
|
+
from nl2sql_api.models.datasource import DatasourceRequest, DatasourceResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DatasourceService:
|
|
8
|
+
def __init__(self, engine: NL2SQL):
|
|
9
|
+
self.engine = engine
|
|
10
|
+
|
|
11
|
+
def add_datasource(self, request: DatasourceRequest) -> DatasourceResponse:
|
|
12
|
+
"""Add a new datasource programmatically."""
|
|
13
|
+
self.engine.add_datasource(request.config)
|
|
14
|
+
return DatasourceResponse(
|
|
15
|
+
success=True,
|
|
16
|
+
message=f"Datasource '{request.config.get('id')}' added successfully",
|
|
17
|
+
datasource_id=request.config.get('id')
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def list_datasources(self) -> list:
|
|
21
|
+
"""List all registered datasources."""
|
|
22
|
+
return self.engine.list_datasources()
|
|
23
|
+
|
|
24
|
+
def get_datasource(self, datasource_id: str) -> dict:
|
|
25
|
+
"""Get details of a specific datasource."""
|
|
26
|
+
datasource_ids = self.engine.list_datasources()
|
|
27
|
+
if datasource_id not in datasource_ids:
|
|
28
|
+
raise ValueError(f"Datasource '{datasource_id}' not found")
|
|
29
|
+
|
|
30
|
+
# In a real implementation, we would return detailed information about the datasource
|
|
31
|
+
return {"datasource_id": datasource_id, "exists": True}
|
|
32
|
+
|
|
33
|
+
def remove_datasource(self, datasource_id: str) -> dict:
|
|
34
|
+
"""Remove a datasource (not directly supported by the engine, but could be implemented)."""
|
|
35
|
+
datasource_ids = self.engine.list_datasources()
|
|
36
|
+
if datasource_id not in datasource_ids:
|
|
37
|
+
raise ValueError(f"Datasource '{datasource_id}' not found")
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
"success": False,
|
|
41
|
+
"message": "Removing datasources is not currently supported by the engine",
|
|
42
|
+
"datasource_id": datasource_id
|
|
43
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
from nl2sql import NL2SQL
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class HealthService:
|
|
6
|
+
def __init__(self, engine: NL2SQL):
|
|
7
|
+
self.engine = engine
|
|
8
|
+
|
|
9
|
+
def health_check(self) -> dict:
|
|
10
|
+
"""Perform health check."""
|
|
11
|
+
return {
|
|
12
|
+
"success": True,
|
|
13
|
+
"message": "NL2SQL API is running"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
def readiness_check(self) -> dict:
|
|
17
|
+
"""Perform readiness check."""
|
|
18
|
+
# Add actual readiness checks (database connections, etc.)
|
|
19
|
+
return {
|
|
20
|
+
"success": True,
|
|
21
|
+
"message": "NL2SQL API is ready"
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
from nl2sql import NL2SQL
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class IndexingService:
|
|
6
|
+
def __init__(self, engine: NL2SQL):
|
|
7
|
+
self.engine = engine
|
|
8
|
+
|
|
9
|
+
def index_datasource(self, datasource_id: str) -> Dict[str, int]:
|
|
10
|
+
"""Index schema for a specific datasource."""
|
|
11
|
+
return self.engine.indexing.index_datasource(datasource_id)
|
|
12
|
+
|
|
13
|
+
def index_all_datasources(self) -> Dict[str, Dict[str, int]]:
|
|
14
|
+
"""Index schema for all registered datasources."""
|
|
15
|
+
return self.engine.indexing.index_all_datasources()
|
|
16
|
+
|
|
17
|
+
def clear_index(self) -> None:
|
|
18
|
+
"""Clear the vector store index."""
|
|
19
|
+
self.engine.indexing.clear_index()
|
|
20
|
+
return {"success": True, "message": "Index cleared successfully"}
|
|
21
|
+
|
|
22
|
+
def get_index_status(self) -> Dict[str, Any]:
|
|
23
|
+
"""Get the status of the index."""
|
|
24
|
+
# This would typically return information about the vector store
|
|
25
|
+
# For now, we'll return a placeholder
|
|
26
|
+
return {
|
|
27
|
+
"status": "operational",
|
|
28
|
+
"indexed_datasources": self.engine.list_datasources(),
|
|
29
|
+
"total_indexes": len(self.engine.list_datasources()) # Placeholder
|
|
30
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Dict, Any, Optional
|
|
2
|
+
from nl2sql import NL2SQL
|
|
3
|
+
from nl2sql_api.models.llm import LLMRequest, LLMResponse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LLMService:
|
|
7
|
+
def __init__(self, engine: NL2SQL):
|
|
8
|
+
self.engine = engine
|
|
9
|
+
|
|
10
|
+
def configure_llm(self, request: LLMRequest) -> LLMResponse:
|
|
11
|
+
"""Configure an LLM programmatically."""
|
|
12
|
+
self.engine.configure_llm(request.config)
|
|
13
|
+
return LLMResponse(
|
|
14
|
+
success=True,
|
|
15
|
+
message=f"LLM '{request.config.get('name', 'default')}' configured successfully",
|
|
16
|
+
llm_name=request.config.get('name', 'default')
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def list_llms(self) -> list:
|
|
20
|
+
"""List all configured LLMs."""
|
|
21
|
+
return self.engine.list_llms()
|
|
22
|
+
|
|
23
|
+
def get_llm(self, llm_name: str) -> dict:
|
|
24
|
+
"""Get details of a specific LLM."""
|
|
25
|
+
return self.engine.get_llm(llm_name)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from nl2sql import NL2SQL
|
|
2
|
+
from nl2sql_api.models.query import QueryRequest, QueryResponse
|
|
3
|
+
from nl2sql.auth.models import UserContext
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QueryService:
|
|
7
|
+
def __init__(self, engine: NL2SQL):
|
|
8
|
+
self.engine = engine
|
|
9
|
+
|
|
10
|
+
def execute_query(self, request: QueryRequest) -> QueryResponse:
|
|
11
|
+
# Convert user_context if provided
|
|
12
|
+
user_context = None
|
|
13
|
+
if request.user_context:
|
|
14
|
+
user_context = UserContext(**request.user_context)
|
|
15
|
+
|
|
16
|
+
result = self.engine.run_query(
|
|
17
|
+
request.natural_language,
|
|
18
|
+
datasource_id=request.datasource_id,
|
|
19
|
+
execute=request.execute,
|
|
20
|
+
user_context=user_context,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return QueryResponse(
|
|
24
|
+
sub_queries=[sub_query.model_dump() for sub_query in result.sub_queries],
|
|
25
|
+
final_answer=result.final_answer,
|
|
26
|
+
errors=result.errors,
|
|
27
|
+
trace_id=result.trace_id,
|
|
28
|
+
reasoning=result.reasoning,
|
|
29
|
+
warnings=result.warnings,
|
|
30
|
+
artifact_refs={
|
|
31
|
+
node_id: ref.model_dump(mode="json")
|
|
32
|
+
for node_id, ref in result.artifact_refs.items()
|
|
33
|
+
},
|
|
34
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nl2sql-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: API layer for NL2SQL engine
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: nl2sql-engine~=0.1
|
|
7
|
+
Requires-Dist: fastapi>=0.100.0
|
|
8
|
+
Requires-Dist: uvicorn>=0.20.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Requires-Dist: python-multipart>=0.0.6
|
|
11
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/nl2sql_api/__init__.py
|
|
4
|
+
src/nl2sql_api/dependencies.py
|
|
5
|
+
src/nl2sql_api/main.py
|
|
6
|
+
src/nl2sql_api/server.py
|
|
7
|
+
src/nl2sql_api.egg-info/PKG-INFO
|
|
8
|
+
src/nl2sql_api.egg-info/SOURCES.txt
|
|
9
|
+
src/nl2sql_api.egg-info/dependency_links.txt
|
|
10
|
+
src/nl2sql_api.egg-info/entry_points.txt
|
|
11
|
+
src/nl2sql_api.egg-info/requires.txt
|
|
12
|
+
src/nl2sql_api.egg-info/top_level.txt
|
|
13
|
+
src/nl2sql_api/models/__init__.py
|
|
14
|
+
src/nl2sql_api/models/datasource.py
|
|
15
|
+
src/nl2sql_api/models/llm.py
|
|
16
|
+
src/nl2sql_api/models/query.py
|
|
17
|
+
src/nl2sql_api/models/response.py
|
|
18
|
+
src/nl2sql_api/models/schema.py
|
|
19
|
+
src/nl2sql_api/routes/__init__.py
|
|
20
|
+
src/nl2sql_api/routes/datasource.py
|
|
21
|
+
src/nl2sql_api/routes/health.py
|
|
22
|
+
src/nl2sql_api/routes/indexing.py
|
|
23
|
+
src/nl2sql_api/routes/llm.py
|
|
24
|
+
src/nl2sql_api/routes/query.py
|
|
25
|
+
src/nl2sql_api/services/__init__.py
|
|
26
|
+
src/nl2sql_api/services/datasource.py
|
|
27
|
+
src/nl2sql_api/services/health.py
|
|
28
|
+
src/nl2sql_api/services/indexing.py
|
|
29
|
+
src/nl2sql_api/services/llm.py
|
|
30
|
+
src/nl2sql_api/services/query.py
|
|
31
|
+
tests/test_app.py
|
|
32
|
+
tests/test_cors.py
|
|
33
|
+
tests/test_query_routes.py
|
|
34
|
+
tests/test_services.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nl2sql_api
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""App wiring tests (ported from packages/api/test_api.py)."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
from nl2sql_api import dependencies
|
|
6
|
+
from nl2sql_api.main import app
|
|
7
|
+
from nl2sql_api.routes import datasource, indexing, llm, query
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_app_is_constructed():
|
|
11
|
+
assert app is not None
|
|
12
|
+
assert app.title == "NL2SQL API"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_dependency_providers_are_callable():
|
|
16
|
+
assert callable(dependencies.get_engine)
|
|
17
|
+
assert callable(dependencies.get_query_service)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_health_endpoint_is_registered():
|
|
21
|
+
paths = app.openapi()["paths"]
|
|
22
|
+
assert "/api/v1/health" in paths
|
|
23
|
+
assert "/api/v1/query" in paths
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_routes_doing_blocking_work_are_synchronous():
|
|
27
|
+
"""`async def` handlers around blocking calls would serialise the whole API."""
|
|
28
|
+
for module in (query, datasource, llm, indexing):
|
|
29
|
+
for name, func in vars(module).items():
|
|
30
|
+
if name.startswith("_") or not inspect.isfunction(func):
|
|
31
|
+
continue
|
|
32
|
+
assert not inspect.iscoroutinefunction(func), f"{module.__name__}.{name} is async"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""CORS configuration tests.
|
|
2
|
+
|
|
3
|
+
The CORS middleware is attached at import time, so each case reloads
|
|
4
|
+
``nl2sql_api.main`` with the environment it wants and restores the module to its
|
|
5
|
+
default (no configured origins) afterwards.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from fastapi.testclient import TestClient
|
|
12
|
+
|
|
13
|
+
import nl2sql_api.main
|
|
14
|
+
|
|
15
|
+
ENV_VAR = "NL2SQL_API_CORS_ORIGINS"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def app_with_cors_env(monkeypatch):
|
|
20
|
+
"""Reload the app under a given ``NL2SQL_API_CORS_ORIGINS`` value."""
|
|
21
|
+
clients = []
|
|
22
|
+
|
|
23
|
+
def _make(origins):
|
|
24
|
+
if origins is None:
|
|
25
|
+
monkeypatch.delenv(ENV_VAR, raising=False)
|
|
26
|
+
else:
|
|
27
|
+
monkeypatch.setenv(ENV_VAR, origins)
|
|
28
|
+
module = importlib.reload(nl2sql_api.main)
|
|
29
|
+
client = TestClient(module.app)
|
|
30
|
+
clients.append(client)
|
|
31
|
+
return client
|
|
32
|
+
|
|
33
|
+
yield _make
|
|
34
|
+
|
|
35
|
+
for client in clients:
|
|
36
|
+
client.close()
|
|
37
|
+
# Restore the module to the default configuration for any later test.
|
|
38
|
+
monkeypatch.delenv(ENV_VAR, raising=False)
|
|
39
|
+
importlib.reload(nl2sql_api.main)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _preflight(client, origin):
|
|
43
|
+
return client.options(
|
|
44
|
+
"/api/v1/health",
|
|
45
|
+
headers={"Origin": origin, "Access-Control-Request-Method": "POST"},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_no_configured_origins_rejects_arbitrary_origin(app_with_cors_env):
|
|
50
|
+
"""Wildcard-with-credentials is unsafe; unset means no cross-origin access."""
|
|
51
|
+
client = app_with_cors_env(None)
|
|
52
|
+
|
|
53
|
+
response = _preflight(client, "https://evil.example")
|
|
54
|
+
|
|
55
|
+
assert response.headers.get("access-control-allow-origin") != "*"
|
|
56
|
+
assert response.headers.get("access-control-allow-origin") is None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_configured_origin_is_echoed_back(app_with_cors_env):
|
|
60
|
+
client = app_with_cors_env("https://bi.corp.example")
|
|
61
|
+
|
|
62
|
+
response = _preflight(client, "https://bi.corp.example")
|
|
63
|
+
|
|
64
|
+
assert response.headers.get("access-control-allow-origin") == "https://bi.corp.example"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_unconfigured_origin_is_rejected_when_others_are_allowed(app_with_cors_env):
|
|
68
|
+
client = app_with_cors_env("https://bi.corp.example")
|
|
69
|
+
|
|
70
|
+
response = _preflight(client, "https://evil.example")
|
|
71
|
+
|
|
72
|
+
assert response.headers.get("access-control-allow-origin") is None
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
from nl2sql.api.query_api import QueryResult, SubQueryResult
|
|
4
|
+
from nl2sql.execution.contracts import ArtifactRef
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _artifact_ref() -> ArtifactRef:
|
|
8
|
+
return ArtifactRef(
|
|
9
|
+
uri="file:///artifacts/sq-1.parquet",
|
|
10
|
+
backend="local",
|
|
11
|
+
format="parquet",
|
|
12
|
+
row_count=2,
|
|
13
|
+
columns=["region", "revenue"],
|
|
14
|
+
bytes=512,
|
|
15
|
+
content_hash="abc123",
|
|
16
|
+
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
17
|
+
path_template="{trace_id}/{node_id}",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _query_result() -> QueryResult:
|
|
22
|
+
return QueryResult(
|
|
23
|
+
sub_queries=[
|
|
24
|
+
SubQueryResult(
|
|
25
|
+
id="sq-1",
|
|
26
|
+
intent="total revenue by region",
|
|
27
|
+
sql="SELECT region, SUM(revenue) FROM sales GROUP BY region",
|
|
28
|
+
datasource_id="warehouse",
|
|
29
|
+
schema_version="v3",
|
|
30
|
+
)
|
|
31
|
+
],
|
|
32
|
+
final_answer={"summary": "Revenue by region", "format_type": "table", "content": "| region |"},
|
|
33
|
+
trace_id="trace-123",
|
|
34
|
+
reasoning=[{"node": "decomposer", "message": "split into 1 sub-query"}],
|
|
35
|
+
warnings=[{"node": "executor", "message": "row limit applied"}],
|
|
36
|
+
artifact_refs={"sq-1": _artifact_ref()},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_execute_query_returns_sql_and_final_answer(api_client):
|
|
41
|
+
client, _ = api_client(_query_result())
|
|
42
|
+
|
|
43
|
+
response = client.post("/api/v1/query", json={"natural_language": "revenue by region"})
|
|
44
|
+
|
|
45
|
+
assert response.status_code == 200
|
|
46
|
+
body = response.json()
|
|
47
|
+
assert body["trace_id"] == "trace-123"
|
|
48
|
+
assert body["final_answer"]["summary"] == "Revenue by region"
|
|
49
|
+
assert len(body["sub_queries"]) == 1
|
|
50
|
+
sub_query = body["sub_queries"][0]
|
|
51
|
+
assert sub_query["id"] == "sq-1"
|
|
52
|
+
assert sub_query["sql"] == "SELECT region, SUM(revenue) FROM sales GROUP BY region"
|
|
53
|
+
assert sub_query["datasource_id"] == "warehouse"
|
|
54
|
+
assert body["reasoning"][0]["node"] == "decomposer"
|
|
55
|
+
assert body["warnings"][0]["node"] == "executor"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_execute_query_returns_artifact_refs_not_rows(api_client):
|
|
59
|
+
client, _ = api_client(_query_result())
|
|
60
|
+
|
|
61
|
+
body = client.post("/api/v1/query", json={"natural_language": "revenue by region"}).json()
|
|
62
|
+
|
|
63
|
+
assert "results" not in body
|
|
64
|
+
assert body["artifact_refs"]["sq-1"]["uri"] == "file:///artifacts/sq-1.parquet"
|
|
65
|
+
assert body["artifact_refs"]["sq-1"]["row_count"] == 2
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_execute_query_forwards_request_options(api_client):
|
|
69
|
+
client, engine = api_client(QueryResult())
|
|
70
|
+
|
|
71
|
+
client.post(
|
|
72
|
+
"/api/v1/query",
|
|
73
|
+
json={
|
|
74
|
+
"natural_language": "revenue by region",
|
|
75
|
+
"datasource_id": "warehouse",
|
|
76
|
+
"execute": False,
|
|
77
|
+
"user_context": {"user_id": "u-1"},
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
call = engine.calls[0]
|
|
82
|
+
assert call["natural_language"] == "revenue by region"
|
|
83
|
+
assert call["datasource_id"] == "warehouse"
|
|
84
|
+
assert call["execute"] is False
|
|
85
|
+
assert call["user_context"].user_id == "u-1"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_pipeline_errors_are_a_200_response(api_client):
|
|
89
|
+
client, _ = api_client(
|
|
90
|
+
QueryResult(
|
|
91
|
+
trace_id="trace-err",
|
|
92
|
+
errors=[{"node": "generator", "message": "no such column", "error_code": "SQL_GEN_FAILED", "severity": "ERROR"}],
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
response = client.post("/api/v1/query", json={"natural_language": "bad query"})
|
|
97
|
+
|
|
98
|
+
assert response.status_code == 200
|
|
99
|
+
body = response.json()
|
|
100
|
+
assert body["errors"][0]["error_code"] == "SQL_GEN_FAILED"
|
|
101
|
+
assert body["sub_queries"] == []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_unexpected_failure_is_a_500_without_internal_detail(api_client):
|
|
105
|
+
client, _ = api_client(RuntimeError("connection string user=admin password=hunter2"))
|
|
106
|
+
|
|
107
|
+
response = client.post("/api/v1/query", json={"natural_language": "revenue by region"})
|
|
108
|
+
|
|
109
|
+
assert response.status_code == 500
|
|
110
|
+
assert "hunter2" not in response.json()["detail"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_invalid_payload_is_a_422(api_client):
|
|
114
|
+
client, _ = api_client(QueryResult())
|
|
115
|
+
|
|
116
|
+
assert client.post("/api/v1/query", json={}).status_code == 422
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_query_route_is_not_a_coroutine():
|
|
120
|
+
"""Blocking pipeline work must not run on the event loop."""
|
|
121
|
+
import inspect
|
|
122
|
+
|
|
123
|
+
from nl2sql_api.routes import query as query_routes
|
|
124
|
+
|
|
125
|
+
assert not inspect.iscoroutinefunction(query_routes.execute_query)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Service-layer tests (ported from packages/api/test_services_updates.py)."""
|
|
2
|
+
|
|
3
|
+
from nl2sql_api.models.datasource import DatasourceRequest
|
|
4
|
+
from nl2sql_api.models.llm import LLMRequest
|
|
5
|
+
from nl2sql_api.services.datasource import DatasourceService
|
|
6
|
+
from nl2sql_api.services.llm import LLMService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeEngine:
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self._datasources = []
|
|
12
|
+
self._llms = {}
|
|
13
|
+
|
|
14
|
+
def add_datasource(self, config):
|
|
15
|
+
self._datasources.append(config["id"])
|
|
16
|
+
|
|
17
|
+
def list_datasources(self):
|
|
18
|
+
return list(self._datasources)
|
|
19
|
+
|
|
20
|
+
def configure_llm(self, config):
|
|
21
|
+
name = config.get("name", "default")
|
|
22
|
+
self._llms[name] = config
|
|
23
|
+
|
|
24
|
+
def list_llms(self):
|
|
25
|
+
return {
|
|
26
|
+
name: {"name": cfg.get("name", name)}
|
|
27
|
+
for name, cfg in self._llms.items()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def get_llm(self, name):
|
|
31
|
+
if name in self._llms:
|
|
32
|
+
return self._llms[name]
|
|
33
|
+
return self._llms.get("default")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_datasource_service_live_update():
|
|
37
|
+
engine = FakeEngine()
|
|
38
|
+
service = DatasourceService(engine)
|
|
39
|
+
|
|
40
|
+
service.add_datasource(DatasourceRequest(config={"id": "ds-1"}))
|
|
41
|
+
|
|
42
|
+
assert "ds-1" in service.list_datasources()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_llm_service_live_update():
|
|
46
|
+
engine = FakeEngine()
|
|
47
|
+
service = LLMService(engine)
|
|
48
|
+
|
|
49
|
+
service.configure_llm(
|
|
50
|
+
LLMRequest(config={"name": "custom", "provider": "openai", "model": "gpt"})
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
llms = service.list_llms()
|
|
54
|
+
assert "custom" in llms
|
|
55
|
+
assert service.get_llm("custom")["name"] == "custom"
|