Skip to content

Commit a364d8b

Browse files
committed
feat: enable custom api key and database upload via frontend
1 parent 9060bf4 commit a364d8b

8 files changed

Lines changed: 267 additions & 35 deletions

File tree

‎agent/retriever.py‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,42 +30,48 @@
3030
_collection: Optional[chromadb.Collection] = None
3131

3232

33-
def _get_collection() -> chromadb.Collection:
34-
"""Lazily initialise and cache the ChromaDB collection."""
35-
global _client, _collection
36-
if _collection is not None:
33+
_last_api_key: Optional[str] = None
34+
35+
def _get_collection(api_key: str | None = None) -> chromadb.Collection:
36+
"""Lazily initialise and cache the ChromaDB collection. Reinitialize if api_key changes."""
37+
global _client, _collection, _last_api_key
38+
39+
current_key = api_key or os.getenv("OPENAI_API_KEY", "")
40+
41+
if _collection is not None and _last_api_key == current_key:
3742
return _collection
3843

3944
persist_dir = os.getenv("CHROMA_PERSIST_DIR", "./chroma_store")
4045
embedding_model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
41-
openai_api_key = os.getenv("OPENAI_API_KEY", "")
4246

4347
_client = chromadb.PersistentClient(path=persist_dir)
4448
embedding_fn = OpenAIEmbeddingFunction(
45-
api_key=openai_api_key,
49+
api_key=current_key,
4650
model_name=embedding_model,
4751
)
4852
_collection = _client.get_collection(
4953
name=COLLECTION_NAME,
5054
embedding_function=embedding_fn,
5155
)
56+
_last_api_key = current_key
5257
return _collection
5358

5459

55-
def get_relevant_schema(query: str, k: int = 3) -> str:
60+
def get_relevant_schema(query: str, k: int = 3, api_key: str | None = None) -> str:
5661
"""
5762
Embed *query*, similarity-search ChromaDB, and return a formatted
5863
table+column block suitable for injection into an LLM prompt.
5964
6065
Args:
6166
query: The user's natural-language question.
6267
k: Number of most relevant tables to retrieve.
68+
api_key: Dynamic OpenAI API Key.
6369
6470
Returns:
6571
A newline-delimited schema block string.
6672
"""
6773
try:
68-
collection = _get_collection()
74+
collection = _get_collection(api_key)
6975
collection_count = collection.count()
7076
if collection_count == 0:
7177
logger.debug("Schema collection is empty; returning empty context for query: %s", query)

‎agent/sql_chain.py‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,21 @@
4949
_llm: ChatOpenAI | None = None
5050

5151

52-
def _get_llm() -> ChatOpenAI:
53-
global _llm
54-
if _llm is None:
55-
_llm = ChatOpenAI(
56-
model=os.getenv("OPENAI_MODEL", "gpt-4o"),
57-
temperature=0,
58-
api_key=os.getenv("OPENAI_API_KEY", ""),
59-
)
52+
_last_llm_key: str | None = None
53+
54+
def _get_llm(api_key: str | None = None) -> ChatOpenAI:
55+
global _llm, _last_llm_key
56+
current_key = api_key or os.getenv("OPENAI_API_KEY", "")
57+
58+
if _llm is not None and _last_llm_key == current_key:
59+
return _llm
60+
61+
_llm = ChatOpenAI(
62+
model=os.getenv("OPENAI_MODEL", "gpt-4o"),
63+
temperature=0,
64+
api_key=current_key,
65+
)
66+
_last_llm_key = current_key
6067
return _llm
6168

6269
SYSTEM_PROMPT = """You are an expert SQL analyst for the Olist Brazilian E-Commerce database.
@@ -193,7 +200,7 @@ def _execute_sql(sql: str) -> list[dict[str, Any]]:
193200
return [dict(zip(columns, row)) for row in rows]
194201

195202

196-
async def run_query(question: str) -> dict[str, Any]:
203+
async def run_query(question: str, api_key: str | None = None) -> dict[str, Any]:
197204
"""
198205
Full LCEL pipeline: natural-language question → SQL → executed results.
199206
@@ -213,7 +220,7 @@ async def run_query(question: str) -> dict[str, Any]:
213220

214221
try:
215222
# Step 1: Retrieve relevant schema snippets via RAG
216-
schema_context = get_relevant_schema(question, k=3)
223+
schema_context = get_relevant_schema(question, k=3, api_key=api_key)
217224

218225
# Step 2: Load few-shot examples
219226
few_shot = _load_few_shot_examples()
@@ -227,7 +234,7 @@ async def run_query(question: str) -> dict[str, Any]:
227234
)
228235

229236
# Step 4: Get cached LLM instance (temperature=0 for deterministic SQL)
230-
llm = _get_llm()
237+
llm = _get_llm(api_key=api_key)
231238

232239
# Step 5: Build LCEL chain: prompt | llm | str parser
233240
chain = prompt | llm | StrOutputParser()

‎api/main.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from fastapi.middleware.cors import CORSMiddleware
1111
from fastapi.responses import JSONResponse
1212

13-
from api.routes import health, query, schema
13+
from api.routes import health, query, schema, upload
1414

1515
load_dotenv()
1616

@@ -53,3 +53,4 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp
5353
app.include_router(query.router)
5454
app.include_router(schema.router)
5555
app.include_router(health.router)
56+
app.include_router(upload.router)

‎api/routes/query.py‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import logging
77
from typing import Any
88

9-
from fastapi import APIRouter, HTTPException
9+
from fastapi import APIRouter, HTTPException, Header
1010
from pydantic import BaseModel, Field
1111

1212
from agent.hitl_guard import check_sql
@@ -15,11 +15,9 @@
1515
logger = logging.getLogger(__name__)
1616
router = APIRouter(prefix="/api", tags=["query"])
1717

18-
1918
class QueryRequest(BaseModel):
2019
question: str = Field(..., min_length=1, description="Natural-language question to convert to SQL")
2120

22-
2321
class QueryResponse(BaseModel):
2422
sql: str
2523
results: list[dict[str, Any]]
@@ -28,26 +26,26 @@ class QueryResponse(BaseModel):
2826
approval_reason: str = ""
2927
latency_ms: int
3028

31-
3229
class ApproveRequest(BaseModel):
3330
sql: str = Field(..., min_length=1)
3431
approved: bool
3532

36-
3733
class ApproveResponse(BaseModel):
3834
executed: bool
3935
results: list[dict[str, Any]]
4036
message: str
4137

42-
4338
@router.post("/query", response_model=QueryResponse)
44-
async def query_endpoint(request: QueryRequest) -> QueryResponse:
39+
async def query_endpoint(
40+
request: QueryRequest,
41+
x_openai_key: str | None = Header(None)
42+
) -> QueryResponse:
4543
"""
4644
Convert a natural-language question to SQL and execute it.
4745
Write operations require explicit approval via /api/approve.
4846
"""
4947
try:
50-
result = await run_query(request.question)
48+
result = await run_query(request.question, api_key=x_openai_key)
5149
return QueryResponse(**result)
5250
except Exception as exc:
5351
logger.error("Query endpoint error: %s", exc, exc_info=True)

‎api/routes/upload.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import os
2+
import sqlite3
3+
import shutil
4+
import logging
5+
from fastapi import APIRouter, UploadFile, File, Header, HTTPException
6+
from model.database import set_engine_url
7+
from agent.semantic_layer import SEMANTIC_SCHEMA
8+
from agent.build_index import build_index
9+
10+
logger = logging.getLogger(__name__)
11+
router = APIRouter()
12+
13+
@router.post("/api/upload")
14+
async def upload_database(
15+
file: UploadFile = File(...),
16+
x_openai_key: str | None = Header(None)
17+
):
18+
try:
19+
# Require API Key to build the Chroma index
20+
api_key = x_openai_key or os.getenv("OPENAI_API_KEY")
21+
if not api_key:
22+
raise HTTPException(status_code=400, detail="OpenAI API Key is required to index the database.")
23+
24+
# Temporarily set the env var so build_index uses it
25+
os.environ["OPENAI_API_KEY"] = api_key
26+
27+
upload_dir = os.path.join(os.getcwd(), "data")
28+
os.makedirs(upload_dir, exist_ok=True)
29+
file_path = os.path.join(upload_dir, "uploaded.db")
30+
31+
with open(file_path, "wb") as buffer:
32+
shutil.copyfileobj(file.file, buffer)
33+
34+
# Update database engine
35+
set_engine_url(f"sqlite:///{file_path}")
36+
37+
# Connect and extract tables
38+
conn = sqlite3.connect(file_path)
39+
cursor = conn.cursor()
40+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
41+
tables = cursor.fetchall()
42+
43+
new_schema = []
44+
for table in tables:
45+
table_name = table[0]
46+
cursor.execute(f"PRAGMA table_info({table_name});")
47+
columns = cursor.fetchall()
48+
49+
new_schema.append({
50+
"table_name": table_name,
51+
"description": f"Custom uploaded table {table_name}",
52+
"columns": [{"name": col[1], "description": f"Column {col[1]} of type {col[2]}"} for col in columns]
53+
})
54+
55+
conn.close()
56+
57+
# Update global semantic schema
58+
SEMANTIC_SCHEMA.clear()
59+
SEMANTIC_SCHEMA.extend(new_schema)
60+
61+
# Build ChromaDB index
62+
build_index()
63+
64+
return {"success": True, "message": f"Successfully loaded and indexed {len(new_schema)} tables."}
65+
except Exception as e:
66+
logger.error(f"Error processing upload: {e}")
67+
raise HTTPException(status_code=500, detail=str(e))

‎frontend/src/api.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
import axios from 'axios'
22

33
const savedUrl = localStorage.getItem('qm_api_url') || '/'
4+
const savedKey = localStorage.getItem('qm_openai_key') || ''
5+
46
const api = axios.create({ baseURL: savedUrl })
57

8+
api.interceptors.request.use((config) => {
9+
const key = localStorage.getItem('qm_openai_key')
10+
if (key && config.headers) {
11+
config.headers['x-openai-key'] = key
12+
}
13+
return config
14+
})
15+
616
export function setApiUrl(url: string) {
717
localStorage.setItem('qm_api_url', url)
818
api.defaults.baseURL = url
@@ -12,6 +22,14 @@ export function getApiUrl(): string {
1222
return localStorage.getItem('qm_api_url') || ''
1323
}
1424

25+
export function setApiKey(key: string) {
26+
localStorage.setItem('qm_openai_key', key)
27+
}
28+
29+
export function getApiKey(): string {
30+
return localStorage.getItem('qm_openai_key') || ''
31+
}
32+
1533
export interface QueryResponse {
1634
sql: string
1735
results: Record<string, unknown>[]
@@ -38,6 +56,29 @@ export interface SchemaTable {
3856
columns: SchemaColumn[]
3957
}
4058

59+
export async function approveQuery(sql: string): Promise<QueryResponse> {
60+
const { data } = await api.post<QueryResponse>('/api/approve', { sql })
61+
return data
62+
}
63+
64+
export async function uploadDatabase(file: File, onProgress?: (percent: number) => void): Promise<{ success: boolean; message: string }> {
65+
const formData = new FormData()
66+
formData.append('file', file)
67+
68+
const { data } = await api.post('/api/upload', formData, {
69+
headers: {
70+
'Content-Type': 'multipart/form-data'
71+
},
72+
onUploadProgress: (progressEvent) => {
73+
if (progressEvent.total && onProgress) {
74+
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
75+
onProgress(percentCompleted)
76+
}
77+
}
78+
})
79+
return data
80+
}
81+
4182
export async function postQuery(question: string): Promise<QueryResponse> {
4283
const { data } = await api.post<QueryResponse>('/api/query', { question })
4384
return data

0 commit comments

Comments
 (0)