ishaq101 sofhiaazzhr commited on
Commit
52999bc
·
1 Parent(s): c8e3046

Merge dev_new to main (improve retriever and add querying) (#13)

Browse files

- [noticket] add gitignore (c87f27f458854787102eebc110b178c2572ef8a6)
- [NOTICKET]: add document pipeline, simplify document API (fb871f3d7db9c5a9c95a012761c4180a432e7e4b)
- [NOTICKET]: update folder document_pipelines after pipelines (a4cf97ab3890e35831c0043a1d411c17d84f374b)
- [NOTICKET][DB] refactor code to new repo (7f3bb9782bb9b6ace69e6be35377c12757a2f81d)
- [KM-441] add mean and median (9b5933420821187e73bb72a896e28d7adf6d6137)
- [NOTICKET] new metadata format for cleaner code (6b590d94c398ac4966a0e376f2809aa52c5ba022)
- update document (5a69e0eecf80dd807565dbc369374e41450c37fc)
- delete duplicate file (3848d7b262745b758069e3ab8089437ced42a45f)
- edit document for new pipeline (425e021053c4de65e2598b0f5554b7c1044a904c)
- [NOTICKET]: add CSV and XLSX file type (31920c3bc6f9faf9e07095a02fc1faf08adc246e)
- [DB] fix/rename db_pipeline.py (d913315c356d741cc7a5a5b1f8e36ba41397f2ab)
- [NOTICKET][DB] menyesuaikan format struktur db_pipeline sesuai dengan file lain (e13a9017e0f7fd26ef9b8ad5981d6c0799c01661)
- [NOTICKET][DB] pisahin db credential ke folder model. add ingestion endpoint at db_client to use db pipeline. add router db_client di main. (347a73aa5597cb28d1f43940eb60db619e4a10be)
- [NOTICKET]: use tesseract for extract PDF (6b9a13d417f6ffffed61a44567a3acbee822e173)
- [NOTICKET]: add Tesseract and Poppler binaries via Git LFS (0a9101a16c713c3facd8c06279da1f01403306a0)
- [NOTICKET]: update uv.lock (bb79f64b69c3bdcb2c98867c5d4945bf2311394d)
- [NOTICKET][DB] update credential & databaseclient. update settings (0e079550f8524da66df365638167dfe42b70ae62)
- [NOTICKET] update settings (65a5c6b1c1539de5d66e700aa5bef011e3c2b2f6)
- [KM-437][DB] add mysql, sqlserver, bigquery, snowflake connections (43539293eab9328d1d6f22f2f837c0a0085485a5)
- [NOTICKET]: adjusted pyproject.toml for OCR PDF (a00e2ad5306306ff73c30d085f126fab7bd1e78c)
- [NOTICKET]: fix merge conflict (6c8734607da68151ab574d0d186b17ac0d8ec460)
- [NOTICKET][DB] fix mysql pipeline (060c8cc81d4de2e85d2f6b4d3de178307e211ad0)
- [NOTICKET] edit imports (b145c06e354030dc38a550cd53030ba4864870ae)
- [NOTICKET] minor code refactor (52415b6a52f08900d27b548459a15c1113c1b892)
- [NOTICKET] add duplicate check for storing database (d310770ffcbcf75cb6b952f86bc6947ab2c7bb6a)
- [NOTICKET][DB] add supported dbtype for frontend (a531fcc7f53fe56a634ebba3b0fd4ff7ad51a33d)
- [NOTICKET]: add doctypes endpoint & 10MB file size limit (9debae56609158434faf47bf2a4a7b70c4137290)
- [NOTICKET]: add comments to flag that file type lists must stay in sync (023b7cfeb1dd6f6c54df748feb1130e4a994bc26)
- [NOTICKET]: add to gitignore (bbc8c584ef676c15d913d04933b9b8ff2b8d98d8)
- Merge branch 'main' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (7757da18fa23f89a4d731cf2648727a97c49b88c)
- Merge branch 'main' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (9c090a04cf8a78322836d04e245632bbf1f04555)
- Merge branch 'main' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (5398fec4138f7f2b944c0a3ddbdf1f858bac7e16)
- Merge branch 'dev_new' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (20bf3f8fa4393217cb50f9e330196b26bb138fa1)
- [NOTICKET] add total token logging (b9703fc5cfad1ec51ef67497559ef4a5235ea3d5)
- [NOTICKET] add updated_at field for metadata & delete old embedding before appending (cb5ab3273516de824aa254ff7c9fed0c5f8098aa)
- [NO TICKET][document]: add updated_at on metadata (d2f7a4833e1069abc3d3831381661def3af4ad8a)
- [NO TICKET][document]: delete vector embedding on table langchain_pg_embedding if user delete document on knowledge (ac3d8c19b4edb19fad7630b7ec5dabc39f9948ac)
- [NOTICKET][document]: make a clean output to status error unsupported file type (2814813f836a095af5b20552ec047cbc29c95821)
- [KM-438][KM-439] framework for knowledge retriever (d1e12641c43e217c684389dd6697f29ffc489226)
- Merge branch 'main' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (a701ac3732bf0eb3da2b13f1801c89537986174e)
- [NOTICKET] fix single source to multiple sources (589ca324a5fec304a2d3652b65311e494c5a44b9)
- [NOTICKET] fixed multiple sources (e9f2a26317988ef59031c32726cae793ad135755)
- [KM-507] add multiple retrieval method to compare (dense, mmr, bm25, hybrid) (ac6b78d1a5448aab8a210416374bd3ffad4300dd)
- [KM-507] add changes to methods (82186504125f65d3767fee5371451847f435a9ea)
- [NOTICKET] add db_client for querying (e49db6011cc3be00efeb0807c02db0bbffa56999)
- add to gitignore (83ed7447e869f2d884d9cd341b7e20bac00f48cd)
- [KM-507] add different methods, now using dense cosine (145bca392470b81e16060feb040ab652f7185976)
- [KM-512] create folder for querying from bd/tabular docs (2c8a3e89bb68fe046beac5dc9934a8d6daaba15d)
- [NOTICKET] minor fix in chat.py, add package for query, change schema used to hybrid (cosine+bm25) (15cd3a7f887abe8994b45d80d4d7ade688120b88)
- [KM-512] add Pydantic model the LLM fills via function calling in sql_query, and add same signature for db and tabular (220f59eb60b88891e476c989b9549cc180cc08b7)
- [NOTICKET] rename file name, updated after uv sync (948d6ddaa2b4bdafef45a219169832d6c7b602bc)
- [NOTICKET] update .gitignore (240251c4050c9a6457522d0024e87f99dbfbbb13)
- Merge branch 'dev_new' of https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond into dev_new (29efec6750e81688c99f62f52b0fdc137da8fee8)
- [KM-513][document] add convert to parquet if type file is XLSX and CSV (770f26b143a795987628fbdb4716ee8c574a6e5c)
- add to gitignore (1fef470b29037577cde6246ec7f417f164fed72e)
- [KM-512] connect query executor to user question. add logging for db_executor (abc494f97718a67af0d237ebac88fb240f3b46d3)
- [NOTICKET] fix delete, now can filter by user (f273db057a8158fe7eac526688ffd52d9ebaf911)
- [NOTICKET] db_executor: CTE DML check now walks entire AST root, schema: cast instead of string interpolation (bd2b1d9ddf6664c2739563fbd04eaca279771ec0)
- [NOTICKET] fix-revert string change (110ee3438ef171e177c53f38b437e6dc8e927a42)
- [KM-520] Integrate db query executor pipeline with existing rag retrieve pipeline (a25febe2e68afe14a0c75352fe746dc9d0157643)
- [KM-516][KM-517] add new feature; ai can now see table & column names that have fk relationship with retrieved result (f86da27bdc2a60f0b38b61db90008094ee4d7146)
- [NOTICKET] fix query now use orchestrator msg, rework db pipeline replace ingestion logic (be9bbd9d4451e320e923627eca36f1442cae1932)
- [NOTICKET] edit settings (9e16c227411e7093aea1bd09b79c5bf60fd0431b)
- [KM 436-439] adjust endpoint for new features (0707f2ba972cd3b62aadd4cee8b445ba9aeb6ede)
- [KM-436-437] edit knowledge handler pipeline (767625e5fb1b1371135439c7b9c6cf1e6389d218)
- [KM-438-439] add retriever feature (ba550a5a0e6cd7a64dc471711116e4ce81402049)


Co-authored-by: Sofhia Az-Zahra <sofhiaazzhr@users.noreply.huggingface.co>

.gitignore CHANGED
@@ -28,6 +28,8 @@ test/users/user_accounts.csv
28
 
29
  CLAUDE.md
30
 
 
 
31
  erd/
32
  playground/
33
  playground_retriever.py
 
28
 
29
  CLAUDE.md
30
 
31
+ /experiments
32
+ src/rag/experiments/
33
  erd/
34
  playground/
35
  playground_retriever.py
pyproject.toml CHANGED
@@ -79,6 +79,8 @@ dependencies = [
79
  "jsonpatch>=1.33",
80
  "pymongo>=4.14.0",
81
  "psycopg2>=2.9.11",
 
 
82
  # --- User-DB connectors (db_pipeline) ---
83
  "pymysql>=1.1.1",
84
  "pymssql>=2.3.0",
 
79
  "jsonpatch>=1.33",
80
  "pymongo>=4.14.0",
81
  "psycopg2>=2.9.11",
82
+ # --- SQL parsing / guardrails ---
83
+ "sqlglot>=25.0.0",
84
  # --- User-DB connectors (db_pipeline) ---
85
  "pymysql>=1.1.1",
86
  "pymssql>=2.3.0",
src/agents/orchestration.py CHANGED
@@ -35,6 +35,11 @@ Intent Routing:
35
  - greeting -> needs_search=False, direct_response="Hello! How can I assist you today?"
36
  - goodbye -> needs_search=False, direct_response="Goodbye! Have a great day!"
37
  - other -> needs_search=True, search_query=<standalone rewritten query>
 
 
 
 
 
38
  """),
39
  MessagesPlaceholder(variable_name="history"),
40
  ("user", "{message}")
 
35
  - greeting -> needs_search=False, direct_response="Hello! How can I assist you today?"
36
  - goodbye -> needs_search=False, direct_response="Goodbye! Have a great day!"
37
  - other -> needs_search=True, search_query=<standalone rewritten query>
38
+
39
+ Source Routing (set source_hint):
40
+ - Columns, tables, sheets, data types, schema, row counts, statistics -> source_hint=schema
41
+ - Document content, paragraphs, reports, articles, text -> source_hint=document
42
+ - Unclear or spans both -> source_hint=both
43
  """),
44
  MessagesPlaceholder(variable_name="history"),
45
  ("user", "{message}")
src/api/v1/chat.py CHANGED
@@ -9,6 +9,9 @@ from src.db.postgres.models import ChatMessage, MessageSource
9
  from src.agents.orchestration import orchestrator
10
  from src.agents.chatbot import chatbot
11
  from src.rag.retriever import retriever
 
 
 
12
  from src.db.redis.connection import get_redis
13
  from src.config.settings import settings
14
  from src.middlewares.logging import get_logger, log_execution
@@ -61,7 +64,7 @@ def _extract_sources(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
61
  seen = set()
62
  sources = []
63
  for result in results:
64
- if "document_id" in result["metadata"]["data"]:
65
  meta = result["metadata"]
66
  key = (meta.get("data", {}).get("document_id"), meta.get("data", {}).get("page_label"))
67
  if key not in seen:
@@ -88,6 +91,22 @@ def _extract_sources(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
88
  return sources
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  async def get_cached_response(redis, cache_key: str) -> Optional[str]:
92
  cached = await redis.get(cache_key)
93
  if cached:
@@ -182,16 +201,25 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
182
 
183
  if not intent_result.get("needs_search"):
184
  retrieval_task.cancel()
 
 
 
 
185
  raw_results = []
186
  else:
187
  search_query = intent_result.get("search_query", request.message)
188
  logger.info(f"Searching for: {search_query}")
189
  if search_query != request.message:
190
  retrieval_task.cancel()
 
 
 
 
191
  raw_results = await retriever.retrieve(
192
  query=search_query,
193
  user_id=request.user_id,
194
  db=db,
 
195
  )
196
  else:
197
  raw_results = await retrieval_task
@@ -199,6 +227,27 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
199
  context = _format_context(raw_results)
200
  sources = _extract_sources(raw_results)
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  # Step 3: Direct response for greetings / non-document intents
203
  if intent_result.get("direct_response"):
204
  response = intent_result["direct_response"]
 
9
  from src.agents.orchestration import orchestrator
10
  from src.agents.chatbot import chatbot
11
  from src.rag.retriever import retriever
12
+ from src.rag.base import RetrievalResult
13
+ from src.query.query_executor import query_executor
14
+ from src.query.base import QueryResult
15
  from src.db.redis.connection import get_redis
16
  from src.config.settings import settings
17
  from src.middlewares.logging import get_logger, log_execution
 
64
  seen = set()
65
  sources = []
66
  for result in results:
67
+ if "document_id" in result["metadata"].get("data", {}):
68
  meta = result["metadata"]
69
  key = (meta.get("data", {}).get("document_id"), meta.get("data", {}).get("page_label"))
70
  if key not in seen:
 
91
  return sources
92
 
93
 
94
+ def _format_query_results(results: list[QueryResult]) -> str:
95
+ if not results:
96
+ return ""
97
+ lines = []
98
+ for r in results:
99
+ name = r.metadata.get("client_name", r.source_id)
100
+ lines.append(f"[Query result — {name}, tables: {r.table_or_file}]")
101
+ lines.append(f"SQL: {r.metadata.get('sql', '')}")
102
+ if r.columns and r.rows:
103
+ lines.append(" | ".join(r.columns))
104
+ for row in r.rows[:20]:
105
+ lines.append(" | ".join(str(row.get(c, "")) for c in r.columns))
106
+ lines.append(f"({r.row_count} rows total)\n")
107
+ return "\n".join(lines)
108
+
109
+
110
  async def get_cached_response(redis, cache_key: str) -> Optional[str]:
111
  cached = await redis.get(cache_key)
112
  if cached:
 
201
 
202
  if not intent_result.get("needs_search"):
203
  retrieval_task.cancel()
204
+ try:
205
+ await retrieval_task
206
+ except asyncio.CancelledError:
207
+ pass
208
  raw_results = []
209
  else:
210
  search_query = intent_result.get("search_query", request.message)
211
  logger.info(f"Searching for: {search_query}")
212
  if search_query != request.message:
213
  retrieval_task.cancel()
214
+ try:
215
+ await retrieval_task
216
+ except asyncio.CancelledError:
217
+ pass
218
  raw_results = await retriever.retrieve(
219
  query=search_query,
220
  user_id=request.user_id,
221
  db=db,
222
+ source_hint=intent_result.get("source_hint", "both"),
223
  )
224
  else:
225
  raw_results = await retrieval_task
 
227
  context = _format_context(raw_results)
228
  sources = _extract_sources(raw_results)
229
 
230
+ source_hint = intent_result.get("source_hint", "both")
231
+ if source_hint in ("schema", "both"):
232
+ retrieval_objects = [
233
+ RetrievalResult(
234
+ content=r["content"],
235
+ metadata=r["metadata"],
236
+ score=0.0,
237
+ source_type=r["metadata"].get("source_type", ""),
238
+ )
239
+ for r in raw_results
240
+ ]
241
+ query_results = await query_executor.execute(
242
+ results=retrieval_objects,
243
+ user_id=request.user_id,
244
+ db=db,
245
+ question=intent_result.get("search_query") or request.message,
246
+ )
247
+ query_context = _format_query_results(query_results)
248
+ if query_context:
249
+ context = query_context + "\n\n" + context
250
+
251
  # Step 3: Direct response for greetings / non-document intents
252
  if intent_result.get("direct_response"):
253
  response = intent_result["direct_response"]
src/api/v1/db_client.py CHANGED
@@ -458,7 +458,7 @@ async def ingest_database_client(
458
  db_type=client.db_type,
459
  credentials=creds,
460
  ) as engine:
461
- total = await db_pipeline_service.run(user_id=user_id, engine=engine)
462
  except NotImplementedError as e:
463
  raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(e))
464
  except Exception as e:
 
458
  db_type=client.db_type,
459
  credentials=creds,
460
  ) as engine:
461
+ total = await db_pipeline_service.run(user_id=user_id, client_id=client_id, engine=engine)
462
  except NotImplementedError as e:
463
  raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(e))
464
  except Exception as e:
src/db/postgres/init_db.py CHANGED
@@ -28,3 +28,38 @@ async def init_db():
28
  await conn.execute(text(
29
  "ALTER TABLE rooms ADD COLUMN IF NOT EXISTS status VARCHAR NOT NULL DEFAULT 'active'"
30
  ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  await conn.execute(text(
29
  "ALTER TABLE rooms ADD COLUMN IF NOT EXISTS status VARCHAR NOT NULL DEFAULT 'active'"
30
  ))
31
+
32
+ # HNSW index for fast approximate vector similarity search
33
+ # Only created when the embedding column has explicit dimensions (HNSW requirement).
34
+ # atttypmod > 0 means the vector column was created with a dimension (e.g. vector(1536));
35
+ # atttypmod = -1 means dimensionless — HNSW would fail with "column does not have dimensions".
36
+ await conn.execute(text("""
37
+ DO $$
38
+ BEGIN
39
+ IF EXISTS (
40
+ SELECT FROM pg_attribute a
41
+ JOIN pg_class c ON c.oid = a.attrelid
42
+ WHERE c.relname = 'langchain_pg_embedding'
43
+ AND a.attname = 'embedding'
44
+ AND a.atttypmod > 0
45
+ ) THEN
46
+ CREATE INDEX IF NOT EXISTS idx_langchain_pg_embedding_hnsw
47
+ ON langchain_pg_embedding USING hnsw (embedding vector_cosine_ops);
48
+ END IF;
49
+ END $$
50
+ """))
51
+
52
+ # GIN index for FTS on schema chunks — only created if table exists
53
+ # (langchain_pg_embedding is created by PGVector on first use, not by create_all)
54
+ await conn.execute(text("""
55
+ DO $$
56
+ BEGIN
57
+ IF EXISTS (
58
+ SELECT FROM information_schema.tables
59
+ WHERE table_name = 'langchain_pg_embedding'
60
+ ) THEN
61
+ CREATE INDEX IF NOT EXISTS idx_langchain_pg_embedding_fts
62
+ ON langchain_pg_embedding USING GIN (to_tsvector('english', document));
63
+ END IF;
64
+ END $$
65
+ """))
src/document/document_service.py CHANGED
@@ -1,8 +1,9 @@
1
  """Service for managing documents."""
2
 
3
  from sqlalchemy.ext.asyncio import AsyncSession
4
- from sqlalchemy import select, delete
5
  from src.db.postgres.models import Document
 
6
  from src.storage.az_blob.az_blob import blob_storage
7
  from src.middlewares.logging import get_logger
8
  from typing import List, Optional
@@ -77,6 +78,21 @@ class DocumentService:
77
  # Delete from blob storage
78
  await blob_storage.delete_file(document.blob_name)
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  # Delete from database
81
  await db.execute(
82
  delete(Document).where(Document.id == document_id)
 
1
  """Service for managing documents."""
2
 
3
  from sqlalchemy.ext.asyncio import AsyncSession
4
+ from sqlalchemy import select, delete, text
5
  from src.db.postgres.models import Document
6
+ from src.db.postgres.connection import _pgvector_engine
7
  from src.storage.az_blob.az_blob import blob_storage
8
  from src.middlewares.logging import get_logger
9
  from typing import List, Optional
 
78
  # Delete from blob storage
79
  await blob_storage.delete_file(document.blob_name)
80
 
81
+ # Delete vector embeddings from pgvector (scoped to user + collection to avoid cross-user over-delete)
82
+ async with _pgvector_engine.begin() as conn:
83
+ await conn.execute(
84
+ text("""
85
+ DELETE FROM langchain_pg_embedding
86
+ WHERE cmetadata->>'user_id' = :user_id
87
+ AND cmetadata->>'source_type' = 'document'
88
+ AND cmetadata->'data'->>'document_id' = :doc_id
89
+ AND collection_id = (
90
+ SELECT uuid FROM langchain_pg_collection WHERE name = 'document_embeddings'
91
+ )
92
+ """),
93
+ {"user_id": document.user_id, "doc_id": document_id},
94
+ )
95
+
96
  # Delete from database
97
  await db.execute(
98
  delete(Document).where(Document.id == document_id)
src/knowledge/parquet_service.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parquet service — converts, uploads, downloads, and deletes Parquet files for CSV/XLSX.
2
+
3
+ Parquet files are stored in Azure Blob alongside the original document using
4
+ a deterministic naming convention based on document_id:
5
+
6
+ CSV: {user_id}/{document_id}.parquet
7
+ XLSX sheet: {user_id}/{document_id}__{safe_sheet_name}.parquet
8
+
9
+ This allows tabular.py to construct the correct blob name at retrieval time
10
+ without needing to store it separately, and allows document_pipeline.py to
11
+ delete all Parquet files for a document using a prefix delete.
12
+ """
13
+
14
+ import io
15
+
16
+ import pandas as pd
17
+
18
+ from src.middlewares.logging import get_logger
19
+ from src.storage.az_blob.az_blob import blob_storage
20
+
21
+ logger = get_logger("parquet_service")
22
+
23
+
24
+ def _safe_sheet_name(sheet_name: str) -> str:
25
+ return sheet_name.replace("/", "_").replace(" ", "_").replace("\\", "_")
26
+
27
+
28
+ def parquet_blob_name(user_id: str, document_id: str, sheet_name: str | None = None) -> str:
29
+ """Construct deterministic Parquet blob name."""
30
+ if sheet_name:
31
+ return f"{user_id}/{document_id}__{_safe_sheet_name(sheet_name)}.parquet"
32
+ return f"{user_id}/{document_id}.parquet"
33
+
34
+
35
+ def _to_parquet_bytes(df: pd.DataFrame) -> bytes:
36
+ buf = io.BytesIO()
37
+ df.to_parquet(buf, index=False)
38
+ return buf.getvalue()
39
+
40
+
41
+ async def upload_parquet(
42
+ df: pd.DataFrame,
43
+ user_id: str,
44
+ document_id: str,
45
+ sheet_name: str | None = None,
46
+ ) -> str:
47
+ """Convert DataFrame to Parquet and upload to Azure Blob. Returns blob_name."""
48
+ blob_name = parquet_blob_name(user_id, document_id, sheet_name)
49
+ parquet_bytes = _to_parquet_bytes(df)
50
+ await blob_storage.upload_bytes(parquet_bytes, blob_name)
51
+ logger.info(f"Uploaded Parquet {blob_name} ({len(parquet_bytes)} bytes)")
52
+ return blob_name
53
+
54
+
55
+ async def download_parquet(
56
+ user_id: str,
57
+ document_id: str,
58
+ sheet_name: str | None = None,
59
+ ) -> pd.DataFrame:
60
+ """Download Parquet from Azure Blob and return as DataFrame."""
61
+ blob_name = parquet_blob_name(user_id, document_id, sheet_name)
62
+ content = await blob_storage.download_file(blob_name)
63
+ df = pd.read_parquet(io.BytesIO(content))
64
+ logger.info(f"Downloaded Parquet {blob_name}: {len(df)} rows, {len(df.columns)} columns")
65
+ return df
66
+
67
+
68
+ async def delete_document_parquets(user_id: str, document_id: str) -> int:
69
+ """Delete all Parquet files for a document (CSV = 1 file, XLSX = one per sheet).
70
+
71
+ Uses prefix delete: {user_id}/{document_id} matches all Parquet variants
72
+ for this document without touching the original blob (which uses a random UUID name).
73
+ """
74
+ prefix = f"{user_id}/{document_id}"
75
+ deleted = await blob_storage.delete_blobs_with_prefix(prefix)
76
+ logger.info(f"Deleted {deleted} Parquet file(s) for document {document_id}")
77
+ return deleted
src/knowledge/processing_service.py CHANGED
@@ -7,7 +7,9 @@ from src.storage.az_blob.az_blob import blob_storage
7
  from src.db.postgres.models import Document as DBDocument
8
  from sqlalchemy.ext.asyncio import AsyncSession
9
  from src.middlewares.logging import get_logger
 
10
  from typing import List
 
11
  import sys
12
  import docx
13
  import pandas as pd
@@ -15,6 +17,8 @@ import pytesseract
15
  from pdf2image import convert_from_bytes
16
  from io import BytesIO
17
 
 
 
18
  logger = get_logger("knowledge_processing")
19
 
20
 
@@ -41,9 +45,9 @@ class KnowledgeProcessingService:
41
  if db_doc.file_type == "pdf":
42
  documents = await self._build_pdf_documents(content, db_doc)
43
  elif db_doc.file_type == "csv":
44
- documents = self._build_csv_documents(content, db_doc)
45
  elif db_doc.file_type == "xlsx":
46
- documents = self._build_excel_documents(content, db_doc)
47
  else:
48
  text = self._extract_text(content, db_doc.file_type)
49
  if not text.strip():
@@ -55,6 +59,7 @@ class KnowledgeProcessingService:
55
  metadata={
56
  "user_id": db_doc.user_id,
57
  "source_type": "document",
 
58
  "data": {
59
  "document_id": db_doc.id,
60
  "filename": db_doc.filename,
@@ -103,6 +108,7 @@ class KnowledgeProcessingService:
103
  metadata={
104
  "user_id": db_doc.user_id,
105
  "source_type": "document",
 
106
  "data": {
107
  "document_id": db_doc.id,
108
  "filename": db_doc.filename,
@@ -150,6 +156,7 @@ class KnowledgeProcessingService:
150
  metadata={
151
  "user_id": db_doc.user_id,
152
  "source_type": "document",
 
153
  "data": {
154
  "document_id": db_doc.id,
155
  "filename": db_doc.filename,
@@ -162,18 +169,25 @@ class KnowledgeProcessingService:
162
  ))
163
  return documents
164
 
165
- def _build_csv_documents(self, content: bytes, db_doc: DBDocument) -> List[LangChainDocument]:
166
- """Profile each column of a CSV file."""
167
  df = pd.read_csv(BytesIO(content))
 
 
168
  return self._profile_dataframe(df, db_doc.filename, db_doc)
169
 
170
- def _build_excel_documents(self, content: bytes, db_doc: DBDocument) -> List[LangChainDocument]:
171
- """Profile each column of every sheet in an Excel file."""
172
  sheets = pd.read_excel(BytesIO(content), sheet_name=None)
173
  documents = []
174
  for sheet_name, df in sheets.items():
175
  source_name = f"{db_doc.filename} / sheet: {sheet_name}"
176
- documents.extend(self._profile_dataframe(df, source_name, db_doc))
 
 
 
 
 
177
  return documents
178
 
179
  def _extract_text(self, content: bytes, file_type: str) -> str:
 
7
  from src.db.postgres.models import Document as DBDocument
8
  from sqlalchemy.ext.asyncio import AsyncSession
9
  from src.middlewares.logging import get_logger
10
+ from src.knowledge.parquet_service import upload_parquet
11
  from typing import List
12
+ from datetime import datetime, timezone, timedelta
13
  import sys
14
  import docx
15
  import pandas as pd
 
17
  from pdf2image import convert_from_bytes
18
  from io import BytesIO
19
 
20
+ _JAKARTA_TZ = timezone(timedelta(hours=7))
21
+
22
  logger = get_logger("knowledge_processing")
23
 
24
 
 
45
  if db_doc.file_type == "pdf":
46
  documents = await self._build_pdf_documents(content, db_doc)
47
  elif db_doc.file_type == "csv":
48
+ documents = await self._build_csv_documents(content, db_doc)
49
  elif db_doc.file_type == "xlsx":
50
+ documents = await self._build_excel_documents(content, db_doc)
51
  else:
52
  text = self._extract_text(content, db_doc.file_type)
53
  if not text.strip():
 
59
  metadata={
60
  "user_id": db_doc.user_id,
61
  "source_type": "document",
62
+ "updated_at": datetime.now(_JAKARTA_TZ).isoformat(),
63
  "data": {
64
  "document_id": db_doc.id,
65
  "filename": db_doc.filename,
 
108
  metadata={
109
  "user_id": db_doc.user_id,
110
  "source_type": "document",
111
+ "updated_at": datetime.now(_JAKARTA_TZ).isoformat(),
112
  "data": {
113
  "document_id": db_doc.id,
114
  "filename": db_doc.filename,
 
156
  metadata={
157
  "user_id": db_doc.user_id,
158
  "source_type": "document",
159
+ "updated_at": datetime.now(_JAKARTA_TZ).isoformat(),
160
  "data": {
161
  "document_id": db_doc.id,
162
  "filename": db_doc.filename,
 
169
  ))
170
  return documents
171
 
172
+ async def _build_csv_documents(self, content: bytes, db_doc: DBDocument) -> List[LangChainDocument]:
173
+ """Profile each column of a CSV file and upload Parquet to Azure Blob."""
174
  df = pd.read_csv(BytesIO(content))
175
+ await upload_parquet(df, db_doc.user_id, db_doc.id)
176
+ logger.info(f"Uploaded Parquet for CSV {db_doc.id}")
177
  return self._profile_dataframe(df, db_doc.filename, db_doc)
178
 
179
+ async def _build_excel_documents(self, content: bytes, db_doc: DBDocument) -> List[LangChainDocument]:
180
+ """Profile each column of every sheet in an Excel file and upload one Parquet per sheet."""
181
  sheets = pd.read_excel(BytesIO(content), sheet_name=None)
182
  documents = []
183
  for sheet_name, df in sheets.items():
184
  source_name = f"{db_doc.filename} / sheet: {sheet_name}"
185
+ docs = self._profile_dataframe(df, source_name, db_doc)
186
+ for doc in docs:
187
+ doc.metadata["data"]["sheet_name"] = sheet_name
188
+ documents.extend(docs)
189
+ await upload_parquet(df, db_doc.user_id, db_doc.id, sheet_name)
190
+ logger.info(f"Uploaded Parquet for sheet '{sheet_name}' of {db_doc.id}")
191
  return documents
192
 
193
  def _extract_text(self, content: bytes, file_type: str) -> str:
src/models/sql_query.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Structured output model for LLM-generated SQL queries."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class SQLQuery(BaseModel):
7
+ sql: str = Field(description="A single SQL SELECT statement. No markdown, no explanation inline.")
8
+ reasoning: str = Field(description="One sentence: what this query answers.")
src/models/structured_output.py CHANGED
@@ -19,3 +19,7 @@ class IntentClassification(BaseModel):
19
  default="",
20
  description="Direct response if no search needed (for greetings, etc.)"
21
  )
 
 
 
 
 
19
  default="",
20
  description="Direct response if no search needed (for greetings, etc.)"
21
  )
22
+ source_hint: str = Field(
23
+ default="both",
24
+ description="Which sources to search: 'document' (PDF/DOCX/TXT), 'schema' (DB/CSV/XLSX), or 'both'"
25
+ )
src/pipeline/db_pipeline/db_pipeline_service.py CHANGED
@@ -148,7 +148,7 @@ class DbPipelineService:
148
  engine.dispose()
149
 
150
  def _to_document(
151
- self, user_id: str, table_name: str, entry: dict, updated_at: str
152
  ) -> LangChainDocument:
153
  col = entry["col"]
154
  return LangChainDocument(
@@ -156,6 +156,7 @@ class DbPipelineService:
156
  metadata={
157
  "user_id": user_id,
158
  "source_type": "database",
 
159
  "updated_at": updated_at,
160
  "data": {
161
  "table_name": table_name,
@@ -170,6 +171,7 @@ class DbPipelineService:
170
  async def run(
171
  self,
172
  user_id: str,
 
173
  engine: Engine,
174
  exclude_tables: Optional[frozenset[str]] = None,
175
  ) -> int:
@@ -181,35 +183,50 @@ class DbPipelineService:
181
  vector_store = get_vector_store()
182
  logger.info("db pipeline start", user_id=user_id)
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  async with _pgvector_engine.begin() as conn:
185
  result = await conn.execute(
186
  text(
187
  "DELETE FROM langchain_pg_embedding "
188
  "WHERE cmetadata->>'user_id' = :user_id "
189
  " AND cmetadata->>'source_type' = 'database' "
 
 
190
  " AND collection_id = ("
191
  " SELECT uuid FROM langchain_pg_collection WHERE name = 'document_embeddings'"
192
  " )"
193
  ),
194
- {"user_id": user_id},
195
  )
196
- logger.info("cleared old db embeddings", user_id=user_id, deleted=result.rowcount)
197
-
198
- schema = await asyncio.to_thread(get_schema, engine, exclude_tables)
199
 
200
- updated_at = datetime.now(timezone(timedelta(hours=7))).isoformat()
201
- total = 0
202
- for table_name, columns in schema.items():
203
- logger.info("profiling table", table=table_name, columns=len(columns))
204
- entries = await asyncio.to_thread(profile_table, engine, table_name, columns)
205
- docs = [self._to_document(user_id, table_name, e, updated_at) for e in entries]
206
- if docs:
207
- await vector_store.aadd_documents(docs)
208
- total += len(docs)
209
- logger.info("ingested chunks", table=table_name, count=len(docs))
210
-
211
- logger.info("db pipeline complete", user_id=user_id, total=total)
212
- return total
213
 
214
 
215
  db_pipeline_service = DbPipelineService()
 
148
  engine.dispose()
149
 
150
  def _to_document(
151
+ self, user_id: str, client_id: str, table_name: str, entry: dict, updated_at: str
152
  ) -> LangChainDocument:
153
  col = entry["col"]
154
  return LangChainDocument(
 
156
  metadata={
157
  "user_id": user_id,
158
  "source_type": "database",
159
+ "database_client_id": client_id,
160
  "updated_at": updated_at,
161
  "data": {
162
  "table_name": table_name,
 
171
  async def run(
172
  self,
173
  user_id: str,
174
+ client_id: str,
175
  engine: Engine,
176
  exclude_tables: Optional[frozenset[str]] = None,
177
  ) -> int:
 
183
  vector_store = get_vector_store()
184
  logger.info("db pipeline start", user_id=user_id)
185
 
186
+ # Profile first — if this fails, old embeddings are untouched
187
+ schema = await asyncio.to_thread(get_schema, engine, exclude_tables)
188
+
189
+ updated_at = datetime.now(timezone(timedelta(hours=7))).isoformat()
190
+ all_docs: list = []
191
+ for table_name, columns in schema.items():
192
+ logger.info("profiling table", table=table_name, columns=len(columns))
193
+ entries = await asyncio.to_thread(profile_table, engine, table_name, columns)
194
+ docs = [self._to_document(user_id, client_id, table_name, e, updated_at) for e in entries]
195
+ all_docs.extend(docs)
196
+ logger.info("profiled table", table=table_name, count=len(docs))
197
+
198
+ # Insert new chunks first; only delete stale chunks after the insert succeeds.
199
+ # Prevents data loss if aadd_documents fails — old embeddings stay queryable
200
+ # until they're proven replaceable. Stale rows are identified by an older
201
+ # updated_at than this run.
202
+ if not all_docs:
203
+ logger.warning(
204
+ "no docs produced from schema; skipping delete to preserve existing embeddings",
205
+ user_id=user_id,
206
+ client_id=client_id,
207
+ )
208
+ return 0
209
+
210
+ await vector_store.aadd_documents(all_docs)
211
+
212
  async with _pgvector_engine.begin() as conn:
213
  result = await conn.execute(
214
  text(
215
  "DELETE FROM langchain_pg_embedding "
216
  "WHERE cmetadata->>'user_id' = :user_id "
217
  " AND cmetadata->>'source_type' = 'database' "
218
+ " AND cmetadata->>'database_client_id' = :client_id "
219
+ " AND cmetadata->>'updated_at' < :updated_at "
220
  " AND collection_id = ("
221
  " SELECT uuid FROM langchain_pg_collection WHERE name = 'document_embeddings'"
222
  " )"
223
  ),
224
+ {"user_id": user_id, "client_id": client_id, "updated_at": updated_at},
225
  )
226
+ logger.info("cleared stale db embeddings", user_id=user_id, deleted=result.rowcount)
 
 
227
 
228
+ logger.info("db pipeline complete", user_id=user_id, total=len(all_docs))
229
+ return len(all_docs)
 
 
 
 
 
 
 
 
 
 
 
230
 
231
 
232
  db_pipeline_service = DbPipelineService()
src/pipeline/document_pipeline/document_pipeline.py CHANGED
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
5
 
6
  from src.document.document_service import document_service
7
  from src.knowledge.processing_service import knowledge_processor
 
8
  from src.middlewares.logging import get_logger
9
  from src.storage.az_blob.az_blob import blob_storage
10
 
@@ -32,7 +33,7 @@ class DocumentPipeline:
32
  if file_type not in SUPPORTED_FILE_TYPES:
33
  raise HTTPException(
34
  status_code=400,
35
- detail=f"Unsupported file type. Supported: {SUPPORTED_FILE_TYPES}",
36
  )
37
 
38
  blob_name = await blob_storage.upload_file(content, file.filename, user_id)
@@ -81,6 +82,9 @@ class DocumentPipeline:
81
 
82
  await document_service.delete_document(db, document_id)
83
 
 
 
 
84
  logger.info(f"Deleted document {document_id} for user {user_id}")
85
  return {"document_id": document_id}
86
 
 
5
 
6
  from src.document.document_service import document_service
7
  from src.knowledge.processing_service import knowledge_processor
8
+ from src.knowledge.parquet_service import delete_document_parquets
9
  from src.middlewares.logging import get_logger
10
  from src.storage.az_blob.az_blob import blob_storage
11
 
 
33
  if file_type not in SUPPORTED_FILE_TYPES:
34
  raise HTTPException(
35
  status_code=400,
36
+ detail=f"Unsupported file type. Supported: {', '.join(SUPPORTED_FILE_TYPES)}",
37
  )
38
 
39
  blob_name = await blob_storage.upload_file(content, file.filename, user_id)
 
82
 
83
  await document_service.delete_document(db, document_id)
84
 
85
+ if document.file_type in ("csv", "xlsx"):
86
+ await delete_document_parquets(user_id, document_id)
87
+
88
  logger.info(f"Deleted document {document_id} for user {user_id}")
89
  return {"document_id": document_id}
90
 
src/query/__init__.py ADDED
File without changes
src/query/base.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared contract for query executors."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from src.rag.base import RetrievalResult
9
+
10
+
11
+ @dataclass
12
+ class QueryResult:
13
+ source_type: str # "database" or "document"
14
+ source_id: str # database_client_id or document_id
15
+ table_or_file: str
16
+ columns: list[str]
17
+ rows: list[dict]
18
+ row_count: int
19
+ metadata: dict = field(default_factory=dict)
20
+ # metadata should include "column_types": {"col_name": "dtype"} when available
21
+
22
+
23
+ class BaseExecutor(ABC):
24
+ @abstractmethod
25
+ async def execute(
26
+ self,
27
+ results: list[RetrievalResult],
28
+ user_id: str,
29
+ db: AsyncSession,
30
+ question: str,
31
+ limit: int = 100,
32
+ ) -> list[QueryResult]: ...
src/query/executors/__init__.py ADDED
File without changes
src/query/executors/db_executor.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Executor for registered database sources (source_type="database").
2
+
3
+ Flow per (client_id, question):
4
+ 1. Collect all relevant (table_name, column_name) pairs from retrieval results.
5
+ 2. Fetch the FULL schema for those tables from PGVector (not just top-k columns).
6
+ 3. Build a schema context string and send to LLM → structured SQLQuery output.
7
+ 4. Validate via sqlglot: SELECT-only, schema-grounded, LIMIT enforced.
8
+ 5. Execute on the user's DB via engine_scope + asyncio.to_thread.
9
+ 6. Return QueryResult per client_id (may span multiple tables via JOINs).
10
+
11
+ Supported db_types: postgres, supabase, mysql.
12
+ Other types are skipped with a warning — they do not raise.
13
+ """
14
+
15
+ import asyncio
16
+ from collections import defaultdict
17
+ from typing import Any
18
+
19
+ import sqlglot
20
+ import sqlglot.expressions as exp
21
+ import tiktoken
22
+ from langchain_core.prompts import ChatPromptTemplate
23
+ from langchain_openai import AzureChatOpenAI
24
+ from sqlalchemy import text
25
+ from sqlalchemy.ext.asyncio import AsyncSession
26
+
27
+ from src.config.settings import settings
28
+ from src.database_client.database_client_service import database_client_service
29
+ from src.db.postgres.connection import _pgvector_engine
30
+ from src.middlewares.logging import get_logger
31
+ from src.models.sql_query import SQLQuery
32
+ from src.pipeline.db_pipeline import db_pipeline_service
33
+ from src.query.base import BaseExecutor, QueryResult
34
+ from src.rag.base import RetrievalResult
35
+ from src.utils.db_credential_encryption import decrypt_credentials_dict
36
+
37
+ logger = get_logger("db_executor")
38
+
39
+ _enc = tiktoken.get_encoding("cl100k_base")
40
+
41
+ _SUPPORTED_DB_TYPES = {"postgres", "supabase", "mysql"}
42
+ _MAX_RETRIES = 3
43
+ _MAX_LIMIT = 500
44
+
45
+ _SQL_SYSTEM_PROMPT = """\
46
+ You are a SQL data analyst working with a user's database.
47
+ Generate a single SQL SELECT statement that answers the user's question.
48
+
49
+ Database dialect: {dialect}
50
+
51
+ Rules:
52
+ - ONLY reference tables and columns listed in the schema below. Do not invent names.
53
+ - Always include a LIMIT clause (max {limit}).
54
+ - Do not use DELETE, UPDATE, INSERT, DROP, TRUNCATE, ALTER, CREATE, or any DDL.
55
+ - Prefer explicit JOINs over subqueries when combining tables.
56
+ - For aggregations, always alias the result column (e.g. COUNT(*) AS order_count).
57
+ - For date filtering, use dialect-appropriate functions ({dialect} syntax).
58
+
59
+ Schema:
60
+ {schema}
61
+
62
+ {error_section}"""
63
+
64
+
65
+ class DbExecutor(BaseExecutor):
66
+ def __init__(self) -> None:
67
+ self._llm = AzureChatOpenAI(
68
+ azure_deployment=settings.azureai_deployment_name_4o,
69
+ openai_api_version=settings.azureai_api_version_4o,
70
+ azure_endpoint=settings.azureai_endpoint_url_4o,
71
+ api_key=settings.azureai_api_key_4o,
72
+ temperature=0,
73
+ )
74
+ self._prompt = ChatPromptTemplate.from_messages([
75
+ ("system", _SQL_SYSTEM_PROMPT),
76
+ ("human", "{question}"),
77
+ ])
78
+ self._chain = self._prompt | self._llm.with_structured_output(SQLQuery)
79
+
80
+ # ------------------------------------------------------------------
81
+ # Public interface
82
+ # ------------------------------------------------------------------
83
+
84
+ async def execute(
85
+ self,
86
+ results: list[RetrievalResult],
87
+ user_id: str,
88
+ db: AsyncSession,
89
+ question: str,
90
+ limit: int = 100,
91
+ ) -> list[QueryResult]:
92
+ db_results = [r for r in results if r.source_type == "database"]
93
+ if not db_results:
94
+ return []
95
+
96
+ # Group by client_id — one SQL generation + execution pass per client
97
+ by_client: dict[str, list[RetrievalResult]] = defaultdict(list)
98
+ for r in db_results:
99
+ client_id = r.metadata.get("database_client_id", "")
100
+ if client_id:
101
+ by_client[client_id].append(r)
102
+ else:
103
+ logger.warning("db result missing database_client_id, skipping")
104
+
105
+ query_results: list[QueryResult] = []
106
+ for client_id, client_results in by_client.items():
107
+ try:
108
+ qr = await self._execute_for_client(client_id, client_results, user_id, db, question, limit)
109
+ if qr:
110
+ query_results.append(qr)
111
+ except Exception as e:
112
+ logger.error("db executor failed for client", client_id=client_id, error=str(e))
113
+
114
+ return query_results
115
+
116
+ # ------------------------------------------------------------------
117
+ # Per-client execution
118
+ # ------------------------------------------------------------------
119
+
120
+ async def _execute_for_client(
121
+ self,
122
+ client_id: str,
123
+ results: list[RetrievalResult],
124
+ user_id: str,
125
+ db: AsyncSession,
126
+ question: str,
127
+ limit: int,
128
+ ) -> QueryResult | None:
129
+ client = await database_client_service.get(db, client_id)
130
+ if not client:
131
+ logger.warning("database client not found", client_id=client_id)
132
+ return None
133
+ if client.user_id != user_id:
134
+ logger.warning("client ownership mismatch", client_id=client_id)
135
+ return None
136
+ if client.db_type not in _SUPPORTED_DB_TYPES:
137
+ logger.warning("unsupported db_type for query execution", db_type=client.db_type)
138
+ return None
139
+
140
+ # Distinct table names from retrieval results, expanded via FK relationships
141
+ table_names = list({
142
+ r.metadata.get("data", {}).get("table_name")
143
+ for r in results
144
+ if r.metadata.get("data", {}).get("table_name")
145
+ })
146
+ table_names = await self._expand_with_fk_tables(client_id, user_id, table_names)
147
+
148
+ full_schema = await self._fetch_full_schema(client_id, table_names, user_id)
149
+ if not full_schema:
150
+ logger.warning("no schema found in vector store", client_id=client_id, tables=table_names)
151
+ return None
152
+
153
+ schema_ctx = self._build_schema_context(full_schema)
154
+ capped_limit = min(limit, _MAX_LIMIT)
155
+ dialect = client.db_type
156
+
157
+ # SQL generation with retry
158
+ validated_sql: str | None = None
159
+ prev_error: str = ""
160
+ prev_reasoning: str = ""
161
+ for attempt in range(_MAX_RETRIES):
162
+ if prev_error:
163
+ error_section = (
164
+ f"Previous attempt reasoning: {prev_reasoning}\n"
165
+ f"Previous attempt failed: {prev_error}\n"
166
+ "Fix the issue above."
167
+ )
168
+ else:
169
+ error_section = ""
170
+ try:
171
+ prompt_text = schema_ctx + error_section + question
172
+ input_tokens = len(_enc.encode(prompt_text))
173
+ logger.info("sql generation input tokens", attempt=attempt + 1, tokens=input_tokens)
174
+
175
+ result: SQLQuery = await self._chain.ainvoke({
176
+ "schema": schema_ctx,
177
+ "dialect": dialect,
178
+ "limit": capped_limit,
179
+ "error_section": error_section,
180
+ "question": question,
181
+ })
182
+ sql = result.sql.strip()
183
+ validation_error = self._validate(sql, full_schema, capped_limit)
184
+ if validation_error:
185
+ prev_error = validation_error
186
+ prev_reasoning = result.reasoning
187
+ logger.warning("sql validation failed", attempt=attempt + 1, error=validation_error)
188
+ continue
189
+ validated_sql = self._enforce_limit(sql, capped_limit)
190
+ output_tokens = len(_enc.encode(result.sql)) + len(_enc.encode(result.reasoning))
191
+ logger.info(
192
+ "sql generated",
193
+ attempt=attempt + 1,
194
+ input_tokens=input_tokens,
195
+ output_tokens=output_tokens,
196
+ total_tokens=input_tokens + output_tokens,
197
+ reasoning=result.reasoning,
198
+ )
199
+ break
200
+ except Exception as e:
201
+ prev_error = str(e)
202
+ logger.warning("sql generation error", attempt=attempt + 1, error=prev_error)
203
+
204
+ if not validated_sql:
205
+ logger.error("sql generation failed after retries", client_id=client_id)
206
+ return None
207
+
208
+ # Execute on user's DB
209
+ creds = decrypt_credentials_dict(client.credentials)
210
+ with db_pipeline_service.engine_scope(client.db_type, creds) as engine:
211
+ rows = await asyncio.to_thread(self._run_sql, engine, validated_sql)
212
+
213
+ column_types = {
214
+ col["name"]: col["type"]
215
+ for cols in full_schema.values()
216
+ for col in cols
217
+ }
218
+ columns = list(rows[0].keys()) if rows else []
219
+
220
+ return QueryResult(
221
+ source_type="database",
222
+ source_id=client_id,
223
+ table_or_file=", ".join(table_names),
224
+ columns=columns,
225
+ rows=rows,
226
+ row_count=len(rows),
227
+ metadata={
228
+ "db_type": client.db_type,
229
+ "client_name": client.name,
230
+ "sql": validated_sql,
231
+ "column_types": {c: column_types.get(c, "unknown") for c in columns},
232
+ },
233
+ )
234
+
235
+ # ------------------------------------------------------------------
236
+ # Schema helpers
237
+ # ------------------------------------------------------------------
238
+
239
+ async def _expand_with_fk_tables(
240
+ self,
241
+ client_id: str,
242
+ user_id: str,
243
+ table_names: list[str],
244
+ ) -> list[str]:
245
+ """Expand table_names with any tables FK-referenced by the retrieved tables.
246
+
247
+ Prevents SQL generation failures when a required table (e.g. orders) wasn't
248
+ returned by retrieval but is referenced via FK from a table that was
249
+ (e.g. order_items.order_id -> orders.id).
250
+ """
251
+ if not table_names:
252
+ return table_names
253
+
254
+ placeholders = ", ".join(f":t{i}" for i in range(len(table_names)))
255
+ sql = text(f"""
256
+ SELECT DISTINCT lpe.cmetadata->'data'->>'foreign_key' AS fk
257
+ FROM langchain_pg_embedding lpe
258
+ JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
259
+ WHERE lpc.name = 'document_embeddings'
260
+ AND lpe.cmetadata->>'user_id' = :user_id
261
+ AND lpe.cmetadata->>'source_type' = 'database'
262
+ AND lpe.cmetadata->>'database_client_id' = :client_id
263
+ AND lpe.cmetadata->'data'->>'table_name' IN ({placeholders})
264
+ AND lpe.cmetadata->'data'->>'foreign_key' IS NOT NULL
265
+ """)
266
+
267
+ params: dict[str, Any] = {"user_id": user_id, "client_id": client_id}
268
+ for i, name in enumerate(table_names):
269
+ params[f"t{i}"] = name
270
+
271
+ async with _pgvector_engine.connect() as conn:
272
+ result = await conn.execute(sql, params)
273
+ rows = result.fetchall()
274
+
275
+ expanded = set(table_names)
276
+ for row in rows:
277
+ fk = row.fk # format: "referred_table.referred_column"
278
+ if fk:
279
+ referred_table = fk.split(".")[0]
280
+ expanded.add(referred_table)
281
+
282
+ if expanded != set(table_names):
283
+ logger.info(
284
+ "expanded tables via FK",
285
+ original=sorted(table_names),
286
+ expanded=sorted(expanded),
287
+ )
288
+
289
+ return list(expanded)
290
+
291
+ async def _fetch_full_schema(
292
+ self,
293
+ client_id: str,
294
+ table_names: list[str],
295
+ user_id: str,
296
+ ) -> dict[str, list[dict[str, Any]]]:
297
+ """Fetch ALL column chunks for the given tables from PGVector.
298
+
299
+ Returns {table_name: [{"name": ..., "type": ..., "is_primary_key": ...,
300
+ "foreign_key": ..., "content": ...}]}
301
+ """
302
+ placeholders = ", ".join(f":t{i}" for i in range(len(table_names)))
303
+ sql = text(f"""
304
+ SELECT lpe.cmetadata, lpe.document
305
+ FROM langchain_pg_embedding lpe
306
+ JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
307
+ WHERE lpc.name = 'document_embeddings'
308
+ AND lpe.cmetadata->>'user_id' = :user_id
309
+ AND lpe.cmetadata->>'source_type' = 'database'
310
+ AND lpe.cmetadata->>'database_client_id' = :client_id
311
+ AND lpe.cmetadata->'data'->>'table_name' IN ({placeholders})
312
+ ORDER BY lpe.cmetadata->'data'->>'table_name', lpe.cmetadata->'data'->>'column_name'
313
+ """)
314
+
315
+ params: dict[str, Any] = {"user_id": user_id, "client_id": client_id}
316
+ for i, name in enumerate(table_names):
317
+ params[f"t{i}"] = name
318
+
319
+ async with _pgvector_engine.connect() as conn:
320
+ result = await conn.execute(sql, params)
321
+ rows = result.fetchall()
322
+
323
+ schema: dict[str, list[dict[str, Any]]] = defaultdict(list)
324
+ for row in rows:
325
+ data = row.cmetadata.get("data", {})
326
+ table = data.get("table_name")
327
+ if table:
328
+ schema[table].append({
329
+ "name": data.get("column_name", ""),
330
+ "type": data.get("column_type", ""),
331
+ "is_primary_key": data.get("is_primary_key", False),
332
+ "foreign_key": data.get("foreign_key"),
333
+ "content": row.document, # chunk text includes top values / samples
334
+ })
335
+ return dict(schema)
336
+
337
+ def _build_schema_context(self, schema: dict[str, list[dict[str, Any]]]) -> str:
338
+ lines: list[str] = []
339
+ for table, columns in schema.items():
340
+ lines.append(f"Table: {table}")
341
+ for col in columns:
342
+ flags = []
343
+ if col["is_primary_key"]:
344
+ flags.append("PRIMARY KEY")
345
+ if col["foreign_key"]:
346
+ flags.append(f"FK -> {col['foreign_key']}")
347
+ flag_str = f" [{', '.join(flags)}]" if flags else ""
348
+ lines.append(f" - {col['name']} {col['type']}{flag_str}")
349
+ # Include sample/top-values line from chunk content if present
350
+ for line in col["content"].splitlines():
351
+ if line.startswith(("Top values:", "Sample values:")):
352
+ lines.append(f" {line}")
353
+ break
354
+ lines.append("")
355
+ return "\n".join(lines).strip()
356
+
357
+ # ------------------------------------------------------------------
358
+ # Guardrails
359
+ # ------------------------------------------------------------------
360
+
361
+ def _validate(self, sql: str, schema: dict[str, list[dict]], limit: int) -> str:
362
+ """Return an error string if validation fails, empty string if OK."""
363
+ # Layer 1: sqlglot parse + SELECT-only check
364
+ try:
365
+ parsed = sqlglot.parse_one(sql)
366
+ except sqlglot.errors.ParseError as e:
367
+ return f"SQL parse error: {e}"
368
+
369
+ if not isinstance(parsed, exp.Select):
370
+ return f"Only SELECT statements are allowed. Got: {type(parsed).__name__}"
371
+
372
+ # Check for DML anywhere in the AST (including writeable CTEs)
373
+ for node in parsed.find_all((exp.Insert, exp.Update, exp.Delete)):
374
+ return f"DML ({type(node).__name__}) is not allowed."
375
+
376
+ # Layer 2: schema grounding — table names
377
+ known_tables = {t.lower() for t in schema}
378
+ for tbl in parsed.find_all(exp.Table):
379
+ name = tbl.name.lower()
380
+ if name and name not in known_tables:
381
+ return f"Unknown table '{tbl.name}'. Only use tables from the schema."
382
+
383
+ # Layer 3: LIMIT enforcement (inject if missing — done before execution)
384
+ return ""
385
+
386
+ # ------------------------------------------------------------------
387
+ # SQL execution
388
+ # ------------------------------------------------------------------
389
+
390
+ def _enforce_limit(self, sql: str, limit: int) -> str:
391
+ """Inject or cap LIMIT using sqlglot AST manipulation."""
392
+ parsed = sqlglot.parse_one(sql)
393
+ existing = parsed.find(exp.Limit)
394
+ if existing:
395
+ current = int(existing.expression.this)
396
+ if current > limit:
397
+ existing.expression.set("this", limit)
398
+ else:
399
+ parsed = parsed.limit(limit)
400
+ return parsed.sql()
401
+
402
+ def _run_sql(self, engine: Any, sql: str) -> list[dict]:
403
+ # Ensure the user DB connection is a read-only credential — sqlglot validation alone is not sufficient.
404
+ with engine.connect() as conn:
405
+ result = conn.execute(text(sql))
406
+ return [dict(row) for row in result.mappings()]
407
+
408
+
409
+ db_executor = DbExecutor()
src/query/executors/tabular.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Executor for tabular document sources (source_type="document", file_type csv/xlsx).
2
+
3
+ Flow:
4
+ 1. Group RetrievalResult chunks by document_id.
5
+ 2. For each document: download bytes from Azure Blob -> read with pandas.
6
+ 3. Filter DataFrame to relevant columns identified by retrieval.
7
+ 4. Return QueryResult per document.
8
+ """
9
+
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from src.middlewares.logging import get_logger
13
+ from src.query.base import BaseExecutor, QueryResult
14
+ from src.rag.base import RetrievalResult
15
+
16
+ logger = get_logger("tabular_executor")
17
+
18
+ _TABULAR_FILE_TYPES = ("csv", "xlsx")
19
+
20
+
21
+ class TabularExecutor(BaseExecutor):
22
+ async def execute(
23
+ self,
24
+ results: list[RetrievalResult],
25
+ user_id: str,
26
+ db: AsyncSession,
27
+ limit: int = 100,
28
+ ) -> list[QueryResult]:
29
+ # TODO: implement
30
+ # 1. filter results where source_type == "document" and file_type in _TABULAR_FILE_TYPES
31
+ # 2. group by document_id -> list of column_names
32
+ # 3. per group: look up Document by document_id -> get blob_name
33
+ # 4. blob_storage.download_file(blob_name) -> pd.read_csv / pd.read_excel
34
+ # 5. df[relevant_columns].head(limit) -> rows as list[dict]
35
+ # 6. return QueryResult per document
36
+ raise NotImplementedError
37
+
38
+
39
+ tabular_executor = TabularExecutor()
src/query/query_executor.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """QueryExecutor — dispatches retrieval results to the appropriate executor by source_type."""
2
+
3
+ import asyncio
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from src.middlewares.logging import get_logger
8
+ from src.query.base import QueryResult
9
+ from src.query.executors.db_executor import db_executor
10
+ from src.query.executors.tabular import tabular_executor
11
+ from src.rag.base import RetrievalResult
12
+
13
+ logger = get_logger("query_executor")
14
+
15
+
16
+ class QueryExecutor:
17
+ async def execute(
18
+ self,
19
+ results: list[RetrievalResult],
20
+ user_id: str,
21
+ db: AsyncSession,
22
+ question: str,
23
+ limit: int = 100,
24
+ ) -> list[QueryResult]:
25
+ db_results = [r for r in results if r.source_type == "database"]
26
+ tabular_results = [
27
+ r for r in results
28
+ if r.source_type == "document"
29
+ and r.metadata.get("data", {}).get("file_type") in ("csv", "xlsx")
30
+ ]
31
+
32
+ async def _empty() -> list[QueryResult]:
33
+ return []
34
+
35
+ batches = await asyncio.gather(
36
+ db_executor.execute(db_results, user_id, db, question, limit) if db_results else _empty(),
37
+ tabular_executor.execute(tabular_results, user_id, db, question, limit) if tabular_results else _empty(),
38
+ return_exceptions=True,
39
+ )
40
+
41
+ query_results: list[QueryResult] = []
42
+ for batch in batches:
43
+ if isinstance(batch, Exception):
44
+ logger.error("executor failed", error=str(batch))
45
+ continue
46
+ query_results.extend(batch)
47
+
48
+ logger.info("query execution complete", total=len(query_results))
49
+ return query_results
50
+
51
+
52
+ query_executor = QueryExecutor()
src/rag/base.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared contract for all retriever implementations."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class RetrievalResult:
10
+ content: str
11
+ metadata: dict[str, Any]
12
+ score: float
13
+ source_type: str # "document" | "database"
14
+
15
+
16
+ class BaseRetriever(ABC):
17
+ @abstractmethod
18
+ async def retrieve(
19
+ self, query: str, user_id: str, k: int = 5
20
+ ) -> list[RetrievalResult]: ...
src/rag/retriever.py CHANGED
@@ -1,69 +1,43 @@
1
- """Service for retrieving relevant documents from vector store."""
 
 
2
 
3
- import hashlib
4
- import json
5
- from src.db.postgres.vector_store import get_vector_store
6
- from src.db.redis.connection import get_redis
7
  from sqlalchemy.ext.asyncio import AsyncSession
 
8
  from src.middlewares.logging import get_logger
9
- from typing import List, Dict, Any
 
 
10
 
11
  logger = get_logger("retriever")
12
 
13
- _RETRIEVAL_CACHE_TTL = 3600 # 1 hour
14
-
15
 
16
  class RetrieverService:
17
- """Service for retrieving relevant documents."""
 
 
 
 
18
 
19
  def __init__(self):
20
- self.vector_store = get_vector_store()
 
 
 
21
 
22
  async def retrieve(
23
  self,
24
  query: str,
25
  user_id: str,
26
  db: AsyncSession,
27
- k: int = 5
28
- ) -> List[Dict[str, Any]]:
29
- """Retrieve relevant chunks for a query, scoped to the user's documents.
30
-
31
- Returns:
32
- List of dicts with keys: content, metadata
33
- metadata includes: document_id, user_id, filename, chunk_index, page_label (if PDF)
34
- """
35
  try:
36
- redis = await get_redis()
37
- query_hash = hashlib.md5(query.encode()).hexdigest()
38
- cache_key = f"retrieval:{user_id}:{query_hash}:{k}"
39
-
40
- cached = await redis.get(cache_key)
41
- if cached:
42
- logger.info("Returning cached retrieval results")
43
- return json.loads(cached)
44
-
45
- logger.info(f"Retrieving for user {user_id}, query: {query[:50]}...")
46
-
47
- docs = await self.vector_store.asimilarity_search(
48
- query=query,
49
- k=k,
50
- filter={"user_id": user_id}
51
- )
52
-
53
- results = [
54
- {
55
- "content": doc.page_content,
56
- "metadata": doc.metadata,
57
- }
58
- for doc in docs
59
- ]
60
-
61
- logger.info(f"Retrieved {len(results)} chunks")
62
- await redis.setex(cache_key, _RETRIEVAL_CACHE_TTL, json.dumps(results))
63
- return results
64
-
65
  except Exception as e:
66
- logger.error("Retrieval failed", error=str(e))
67
  return []
68
 
69
 
 
1
+ """Public retrieval API thin wrapper around RetrievalRouter."""
2
+
3
+ from typing import Any
4
 
 
 
 
 
5
  from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
  from src.middlewares.logging import get_logger
8
+ from src.rag.retrievers.document import document_retriever
9
+ from src.rag.retrievers.schema import schema_retriever
10
+ from src.rag.router import RetrievalRouter, SourceHint
11
 
12
  logger = get_logger("retriever")
13
 
 
 
14
 
15
  class RetrieverService:
16
+ """Public retrieval service used by chat.py and search tools.
17
+
18
+ Delegates to RetrievalRouter which dispatches based on source_hint.
19
+ Returns List[Dict] to preserve backward compatibility with chat.py.
20
+ """
21
 
22
  def __init__(self):
23
+ self._router = RetrievalRouter(
24
+ schema_retriever=schema_retriever,
25
+ document_retriever=document_retriever,
26
+ )
27
 
28
  async def retrieve(
29
  self,
30
  query: str,
31
  user_id: str,
32
  db: AsyncSession,
33
+ k: int = 5,
34
+ source_hint: SourceHint = "both",
35
+ ) -> list[dict[str, Any]]:
 
 
 
 
 
36
  try:
37
+ results = await self._router.retrieve(query, user_id, source_hint, k)
38
+ return [{"content": r.content, "metadata": r.metadata} for r in results]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  except Exception as e:
40
+ logger.error("retrieval failed", error=str(e))
41
  return []
42
 
43
 
src/rag/retrievers/__init__.py ADDED
File without changes
src/rag/retrievers/baseline.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service for retrieving relevant documents from vector store."""
2
+
3
+ import hashlib
4
+ import json
5
+ from src.db.postgres.vector_store import get_vector_store
6
+ from src.db.redis.connection import get_redis
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from src.middlewares.logging import get_logger
9
+ from typing import List, Dict, Any
10
+
11
+ logger = get_logger("retriever")
12
+
13
+ _RETRIEVAL_CACHE_TTL = 3600 # 1 hour
14
+
15
+
16
+ class RetrieverService:
17
+ """Service for retrieving relevant documents."""
18
+
19
+ def __init__(self):
20
+ self.vector_store = get_vector_store()
21
+
22
+ async def retrieve(
23
+ self,
24
+ query: str,
25
+ user_id: str,
26
+ db: AsyncSession,
27
+ k: int = 5
28
+ ) -> List[Dict[str, Any]]:
29
+ """Retrieve relevant chunks for a query, scoped to the user's documents.
30
+
31
+ Returns:
32
+ List of dicts with keys: content, metadata
33
+ metadata includes: document_id, user_id, filename, chunk_index, page_label (if PDF)
34
+ """
35
+ try:
36
+ redis = await get_redis()
37
+ query_hash = hashlib.md5(query.encode()).hexdigest()
38
+ cache_key = f"retrieval:{user_id}:{query_hash}:{k}"
39
+
40
+ cached = await redis.get(cache_key)
41
+ if cached:
42
+ logger.info("Returning cached retrieval results")
43
+ return json.loads(cached)
44
+
45
+ logger.info(f"Retrieving for user {user_id}, query: {query[:50]}...")
46
+
47
+ docs = await self.vector_store.asimilarity_search(
48
+ query=query,
49
+ k=k,
50
+ filter={"user_id": user_id}
51
+ )
52
+
53
+ results = [
54
+ {
55
+ "content": doc.page_content,
56
+ "metadata": doc.metadata,
57
+ }
58
+ for doc in docs
59
+ ]
60
+
61
+ logger.info(f"Retrieved {len(results)} chunks")
62
+ await redis.setex(cache_key, _RETRIEVAL_CACHE_TTL, json.dumps(results))
63
+ return results
64
+
65
+ except Exception as e:
66
+ logger.error("Retrieval failed", error=str(e))
67
+ return []
68
+
69
+
70
+ retriever = RetrieverService()
src/rag/retrievers/document.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Document retriever — handles PDF, DOCX, TXT chunks (source_type="document", non-tabular).
2
+
3
+ TEAMMATE: implement retrieve() below.
4
+ Strategy: MMR (amax_marginal_relevance_search) + score threshold to avoid returning
5
+ near-identical chunks from the same PDF page.
6
+ Filter: source_type="document" AND data->>'file_type' NOT IN ('csv', 'xlsx')
7
+ """
8
+
9
+ from src.db.postgres.vector_store import get_vector_store
10
+ from src.middlewares.logging import get_logger
11
+ from src.rag.base import BaseRetriever, RetrievalResult
12
+
13
+ logger = get_logger("document_retriever")
14
+
15
+ _SCORE_THRESHOLD = 0.45 # discard chunks with cosine distance above this
16
+
17
+
18
+ class DocumentRetriever(BaseRetriever):
19
+ def __init__(self):
20
+ self.vector_store = get_vector_store()
21
+
22
+ async def retrieve(
23
+ self, query: str, user_id: str, k: int = 5
24
+ ) -> list[RetrievalResult]:
25
+ # TODO (teammate): implement MMR retrieval for prose documents
26
+ # Filter: {"user_id": user_id, "source_type": "document"}
27
+ # then post-filter to exclude file_type in ("csv", "xlsx")
28
+ logger.info("document retriever not yet implemented — returning empty")
29
+ return []
30
+
31
+
32
+ document_retriever = DocumentRetriever()
src/rag/retrievers/schema.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Schema retriever — handles DB schemas (source_type="database") and tabular file
2
+ columns stored as source_type="document" with file_type in ("csv","xlsx").
3
+
4
+ Multiple retrieval strategies are exposed for benchmarking. The active strategy
5
+ used by the router is `retrieve()`, which dispatches to ACTIVE_STRATEGY.
6
+ Change ACTIVE_STRATEGY at module level to switch without touching the router.
7
+
8
+ All strategies embed the query exactly once, then fan out to parallel SQL legs.
9
+
10
+ Vector distance strategies:
11
+ dense_no_threshold — cosine (<=>), no score floor, always returns k chunks
12
+ dense_dot — inner product (<#>), equivalent to cosine for normalized embeddings
13
+ dense_l2 — L2/euclidean (<->), monotonic with cosine on unit-sphere vectors
14
+ hybrid — RRF merge of dense + FTS (database + tabular)
15
+ hybrid_bm25 — RRF merge of dense + FTS (database only)
16
+ """
17
+
18
+ import asyncio
19
+ import time
20
+ from typing import Literal
21
+
22
+ from sqlalchemy import text
23
+
24
+ from src.db.postgres.connection import _pgvector_engine
25
+ from src.db.postgres.vector_store import get_vector_store
26
+ from src.middlewares.logging import get_logger
27
+ from src.rag.base import BaseRetriever, RetrievalResult
28
+
29
+ logger = get_logger("schema_retriever")
30
+
31
+ _TABULAR_FILE_TYPES = ("csv", "xlsx")
32
+
33
+ Strategy = Literal["dense_no_threshold", "dense_dot", "dense_l2", "hybrid", "hybrid_bm25"]
34
+ ACTIVE_STRATEGY: Strategy = "hybrid_bm25"
35
+
36
+
37
+ class SchemaRetriever(BaseRetriever):
38
+ def __init__(self):
39
+ self.vector_store = get_vector_store()
40
+
41
+ # ------------------------------------------------------------------
42
+ # Internal helpers
43
+ # ------------------------------------------------------------------
44
+
45
+ async def _embed_query(self, query: str) -> list[float]:
46
+ return await asyncio.to_thread(self.vector_store.embeddings.embed_query, query)
47
+
48
+ async def _search_db(
49
+ self, embedding: list[float], user_id: str, k: int, operator: str = "<=>"
50
+ ) -> list[RetrievalResult]:
51
+ """Vector search over database chunks. Accepts a pre-computed embedding."""
52
+ emb_str = "[" + ",".join(str(x) for x in embedding) + "]"
53
+
54
+ if operator == "<#>":
55
+ score_sql = f"(lpe.embedding <#> '{emb_str}'::vector) * -1"
56
+ elif operator == "<->":
57
+ score_sql = f"1.0 / (1.0 + (lpe.embedding <-> '{emb_str}'::vector))"
58
+ else:
59
+ score_sql = f"1.0 - (lpe.embedding <=> '{emb_str}'::vector)"
60
+
61
+ sql = text(f"""
62
+ SELECT lpe.document, lpe.cmetadata, {score_sql} AS score
63
+ FROM langchain_pg_embedding lpe
64
+ JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
65
+ WHERE lpc.name = 'document_embeddings'
66
+ AND lpe.cmetadata->>'user_id' = :user_id
67
+ AND lpe.cmetadata->>'source_type' = 'database'
68
+ ORDER BY lpe.embedding {operator} '{emb_str}'::vector ASC
69
+ LIMIT :k
70
+ """)
71
+
72
+ async with _pgvector_engine.connect() as conn:
73
+ result = await conn.execute(sql, {"user_id": user_id, "k": k * 4})
74
+ rows = result.fetchall()
75
+
76
+ return [
77
+ RetrievalResult(
78
+ content=row.document,
79
+ metadata=row.cmetadata,
80
+ score=float(row.score),
81
+ source_type="database",
82
+ )
83
+ for row in rows
84
+ ]
85
+
86
+ async def _search_tabular(
87
+ self, embedding: list[float], user_id: str, k: int, operator: str = "<=>"
88
+ ) -> list[RetrievalResult]:
89
+ """Vector search over tabular document chunks. Accepts a pre-computed embedding."""
90
+ emb_str = "[" + ",".join(str(x) for x in embedding) + "]"
91
+
92
+ if operator == "<#>":
93
+ score_sql = f"(lpe.embedding <#> '{emb_str}'::vector) * -1"
94
+ elif operator == "<->":
95
+ score_sql = f"1.0 / (1.0 + (lpe.embedding <-> '{emb_str}'::vector))"
96
+ else:
97
+ score_sql = f"1.0 - (lpe.embedding <=> '{emb_str}'::vector)"
98
+
99
+ sql = text(f"""
100
+ SELECT lpe.document, lpe.cmetadata, {score_sql} AS score
101
+ FROM langchain_pg_embedding lpe
102
+ JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
103
+ WHERE lpc.name = 'document_embeddings'
104
+ AND lpe.cmetadata->>'user_id' = :user_id
105
+ AND lpe.cmetadata->>'source_type' = 'document'
106
+ AND (lpe.cmetadata->'data'->>'file_type' = 'csv'
107
+ OR lpe.cmetadata->'data'->>'file_type' = 'xlsx')
108
+ ORDER BY lpe.embedding {operator} '{emb_str}'::vector ASC
109
+ LIMIT :k
110
+ """)
111
+
112
+ async with _pgvector_engine.connect() as conn:
113
+ result = await conn.execute(sql, {"user_id": user_id, "k": k * 4})
114
+ rows = result.fetchall()
115
+
116
+ results = []
117
+ for row in rows:
118
+ results.append(
119
+ RetrievalResult(
120
+ content=row.document,
121
+ metadata=row.cmetadata,
122
+ score=float(row.score),
123
+ source_type="document",
124
+ )
125
+ )
126
+ if len(results) >= k:
127
+ break
128
+ return results
129
+
130
+ async def _search_fts_db(self, query: str, user_id: str, k: int) -> list[RetrievalResult]:
131
+ """Full-text search over DB schema chunks using PostgreSQL tsvector.
132
+
133
+ Requires GIN index on langchain_pg_embedding.document (created by init_db.py).
134
+ """
135
+ sql = text("""
136
+ SELECT lpe.document, lpe.cmetadata,
137
+ ts_rank(to_tsvector('english', lpe.document),
138
+ plainto_tsquery('english', :query)) AS rank
139
+ FROM langchain_pg_embedding lpe
140
+ JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
141
+ WHERE lpc.name = 'document_embeddings'
142
+ AND lpe.cmetadata->>'user_id' = :user_id
143
+ AND lpe.cmetadata->>'source_type' = 'database'
144
+ AND to_tsvector('english', lpe.document) @@ plainto_tsquery('english', :query)
145
+ ORDER BY rank DESC
146
+ LIMIT :k
147
+ """)
148
+
149
+ async with _pgvector_engine.connect() as conn:
150
+ result = await conn.execute(sql, {"query": query, "user_id": user_id, "k": k})
151
+ rows = result.fetchall()
152
+
153
+ return [
154
+ RetrievalResult(
155
+ content=row.document,
156
+ metadata=row.cmetadata,
157
+ score=float(row.rank),
158
+ source_type="database",
159
+ )
160
+ for row in rows
161
+ ]
162
+
163
+ async def _search_fts_tabular(self, query: str, user_id: str, k: int) -> list[RetrievalResult]:
164
+ """Full-text search over tabular document chunks using PostgreSQL tsvector."""
165
+ sql = text("""
166
+ SELECT lpe.document, lpe.cmetadata,
167
+ ts_rank(to_tsvector('english', lpe.document),
168
+ plainto_tsquery('english', :query)) AS rank
169
+ FROM langchain_pg_embedding lpe
170
+ JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
171
+ WHERE lpc.name = 'document_embeddings'
172
+ AND lpe.cmetadata->>'user_id' = :user_id
173
+ AND lpe.cmetadata->>'source_type' = 'document'
174
+ AND (lpe.cmetadata->'data'->>'file_type' = 'csv'
175
+ OR lpe.cmetadata->'data'->>'file_type' = 'xlsx')
176
+ AND to_tsvector('english', lpe.document) @@ plainto_tsquery('english', :query)
177
+ ORDER BY rank DESC
178
+ LIMIT :k
179
+ """)
180
+
181
+ async with _pgvector_engine.connect() as conn:
182
+ result = await conn.execute(sql, {"query": query, "user_id": user_id, "k": k})
183
+ rows = result.fetchall()
184
+
185
+ return [
186
+ RetrievalResult(
187
+ content=row.document,
188
+ metadata=row.cmetadata,
189
+ score=float(row.rank),
190
+ source_type="document",
191
+ )
192
+ for row in rows
193
+ ]
194
+
195
+ def _rrf_merge(
196
+ self,
197
+ *ranked_lists: list[RetrievalResult],
198
+ k_rrf: int = 60,
199
+ top_k: int = 5,
200
+ ) -> list[RetrievalResult]:
201
+ """Reciprocal Rank Fusion — combines ranked lists using rank positions only."""
202
+ scores: dict[tuple, float] = {}
203
+ index: dict[tuple, RetrievalResult] = {}
204
+
205
+ for ranked in ranked_lists:
206
+ for rank, result in enumerate(ranked):
207
+ data = result.metadata.get("data", {})
208
+ key = (data.get("table_name"), data.get("column_name") or data.get("filename"))
209
+ scores[key] = scores.get(key, 0.0) + 1.0 / (k_rrf + rank + 1)
210
+ if key not in index or result.score > index[key].score:
211
+ index[key] = result
212
+
213
+ def _key(r: RetrievalResult) -> tuple:
214
+ d = r.metadata.get("data", {})
215
+ return (d.get("table_name"), d.get("column_name") or d.get("filename"))
216
+
217
+ merged = sorted(index.values(), key=lambda r: scores[_key(r)], reverse=True)
218
+ return merged[:top_k]
219
+
220
+ def _dedup(self, results: list[RetrievalResult]) -> list[RetrievalResult]:
221
+ """Deduplicate by (table_name, column_name), keeping highest score per unique column."""
222
+ seen: dict[tuple, RetrievalResult] = {}
223
+ for r in results:
224
+ data = r.metadata.get("data", {})
225
+ key = (data.get("table_name"), data.get("column_name") or data.get("filename"))
226
+ if key not in seen or r.score > seen[key].score:
227
+ seen[key] = r
228
+ return sorted(seen.values(), key=lambda r: r.score, reverse=True)
229
+
230
+ # ------------------------------------------------------------------
231
+ # Named strategies — one embed call each, legs run in parallel
232
+ # ------------------------------------------------------------------
233
+
234
+ async def dense_no_threshold(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
235
+ """Cosine similarity, no score cutoff — always returns k chunks."""
236
+ embedding = await self._embed_query(query)
237
+ db_results, tabular_results = await asyncio.gather(
238
+ self._search_db(embedding, user_id, k),
239
+ self._search_tabular(embedding, user_id, k),
240
+ )
241
+ return self._dedup(db_results + tabular_results)[:k]
242
+
243
+ async def dense_dot(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
244
+ """Inner product similarity (<#>).
245
+
246
+ For L2-normalized embeddings (OpenAI), ranking is identical to cosine.
247
+ Score = raw inner product (not bounded to [0,1]).
248
+ """
249
+ embedding = await self._embed_query(query)
250
+ db_results, tabular_results = await asyncio.gather(
251
+ self._search_db(embedding, user_id, k, "<#>"),
252
+ self._search_tabular(embedding, user_id, k, "<#>"),
253
+ )
254
+ return self._dedup(db_results + tabular_results)[:k]
255
+
256
+ async def dense_l2(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
257
+ """L2 (Euclidean) distance similarity (<->).
258
+
259
+ For L2-normalized embeddings (OpenAI), ranking order matches cosine.
260
+ Score = 1 / (1 + l2_distance), bounded to (0, 1].
261
+ """
262
+ embedding = await self._embed_query(query)
263
+ db_results, tabular_results = await asyncio.gather(
264
+ self._search_db(embedding, user_id, k, "<->"),
265
+ self._search_tabular(embedding, user_id, k, "<->"),
266
+ )
267
+ return self._dedup(db_results + tabular_results)[:k]
268
+
269
+ async def hybrid(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
270
+ """RRF merge of dense + FTS over both database and tabular sources.
271
+
272
+ Embeds once, then runs all four legs (dense db, dense tabular, fts db,
273
+ fts tabular) in a single asyncio.gather.
274
+ """
275
+ embedding = await self._embed_query(query)
276
+ db_results, tabular_results, fts_db, fts_tabular = await asyncio.gather(
277
+ self._search_db(embedding, user_id, k),
278
+ self._search_tabular(embedding, user_id, k),
279
+ self._search_fts_db(query, user_id, k * 4),
280
+ self._search_fts_tabular(query, user_id, k * 4),
281
+ )
282
+ dense = self._dedup(db_results + tabular_results)[:k]
283
+ fts_all = self._dedup(fts_db + fts_tabular)
284
+ return self._rrf_merge(dense, fts_all, top_k=k)
285
+
286
+ async def hybrid_bm25(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
287
+ """RRF merge of dense + FTS (database chunks only).
288
+
289
+ Embeds once, then runs dense db, dense tabular, and fts db legs in parallel.
290
+ """
291
+ embedding = await self._embed_query(query)
292
+ db_results, tabular_results, fts_results = await asyncio.gather(
293
+ self._search_db(embedding, user_id, k),
294
+ self._search_tabular(embedding, user_id, k),
295
+ self._search_fts_db(query, user_id, k * 4),
296
+ )
297
+ dense = self._dedup(db_results + tabular_results)[:k]
298
+ return self._rrf_merge(dense, self._dedup(fts_results), top_k=k)
299
+
300
+ # ------------------------------------------------------------------
301
+ # Public interface — called by the router
302
+ # ------------------------------------------------------------------
303
+
304
+ async def retrieve(self, query: str, user_id: str, k: int = 5) -> list[RetrievalResult]:
305
+ strategy_fn = getattr(self, ACTIVE_STRATEGY)
306
+ results = await strategy_fn(query, user_id, k)
307
+ logger.info("schema retrieval", strategy=ACTIVE_STRATEGY, count=len(results))
308
+ return results
309
+
310
+
311
+ # ------------------------------------------------------------------
312
+ # Benchmark helper — import in test scripts
313
+ # ------------------------------------------------------------------
314
+
315
+ async def benchmark(
316
+ query: str,
317
+ user_id: str,
318
+ k: int = 5,
319
+ strategies: list[Strategy] | None = None,
320
+ ) -> dict[str, dict]:
321
+ """Run multiple strategies against the same query and return timing + results."""
322
+ retriever = SchemaRetriever()
323
+ targets: list[Strategy] = strategies or [
324
+ "dense_no_threshold",
325
+ "dense_dot",
326
+ "dense_l2",
327
+ "hybrid",
328
+ "hybrid_bm25",
329
+ ]
330
+ report: dict[str, dict] = {}
331
+
332
+ for name in targets:
333
+ fn = getattr(retriever, name)
334
+ t0 = time.perf_counter()
335
+ chunks = await fn(query, user_id, k)
336
+ elapsed_ms = round((time.perf_counter() - t0) * 1000)
337
+
338
+ total_chars = sum(len(r.content) for r in chunks)
339
+ report[name] = {
340
+ "chunks": len(chunks),
341
+ "estimated_tokens": total_chars // 4,
342
+ "elapsed_ms": elapsed_ms,
343
+ "results": chunks,
344
+ }
345
+
346
+ return report
347
+
348
+
349
+ schema_retriever = SchemaRetriever()
src/rag/router.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Routes retrieval requests to the appropriate retriever based on source_hint."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ from typing import Literal
7
+
8
+ from src.db.redis.connection import get_redis
9
+ from src.middlewares.logging import get_logger
10
+ from src.rag.base import BaseRetriever, RetrievalResult
11
+
12
+ logger = get_logger("retrieval_router")
13
+
14
+ _CACHE_TTL = 3600 # 1 hour
15
+ SourceHint = Literal["document", "schema", "both"]
16
+
17
+
18
+ class RetrievalRouter:
19
+ def __init__(
20
+ self,
21
+ schema_retriever: BaseRetriever,
22
+ document_retriever: BaseRetriever,
23
+ ):
24
+ self._retrievers: dict[str, BaseRetriever] = {
25
+ "schema": schema_retriever,
26
+ "document": document_retriever,
27
+ }
28
+
29
+ def _route(self, source_hint: SourceHint) -> list[BaseRetriever]:
30
+ if source_hint == "schema":
31
+ return [self._retrievers["schema"]]
32
+ if source_hint == "document":
33
+ return [self._retrievers["document"]]
34
+ return list(self._retrievers.values())
35
+
36
+ async def retrieve(
37
+ self,
38
+ query: str,
39
+ user_id: str,
40
+ source_hint: SourceHint = "both",
41
+ k: int = 10,
42
+ ) -> list[RetrievalResult]:
43
+ redis = await get_redis()
44
+ query_hash = hashlib.md5(query.encode()).hexdigest()
45
+ cache_key = f"retrieval:{user_id}:{source_hint}:{query_hash}:{k}"
46
+
47
+ cached = await redis.get(cache_key)
48
+ if cached:
49
+ logger.info("returning cached retrieval results", source_hint=source_hint)
50
+ raw = json.loads(cached)
51
+ return [RetrievalResult(**r) for r in raw]
52
+
53
+ retrievers = self._route(source_hint)
54
+ batches = await asyncio.gather(
55
+ *[r.retrieve(query, user_id, k) for r in retrievers],
56
+ return_exceptions=True,
57
+ )
58
+
59
+ results: list[RetrievalResult] = []
60
+ for batch in batches:
61
+ if isinstance(batch, Exception):
62
+ logger.error("retriever failed", error=str(batch))
63
+ continue
64
+ results.extend(batch)
65
+
66
+ results.sort(key=lambda r: r.score, reverse=True)
67
+ results = results[:k]
68
+
69
+ logger.info("retrieved chunks", count=len(results), source_hint=source_hint)
70
+ await redis.setex(
71
+ cache_key,
72
+ _CACHE_TTL,
73
+ json.dumps([vars(r) for r in results]),
74
+ )
75
+ return results
src/storage/az_blob/az_blob.py CHANGED
@@ -57,6 +57,22 @@ class AzureBlobStorage:
57
  logger.error(f"Failed to download blob {blob_name}", error=str(e))
58
  raise
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  async def delete_file(self, blob_name: str) -> bool:
61
  """Delete file from Azure Blob Storage."""
62
  try:
@@ -71,6 +87,24 @@ class AzureBlobStorage:
71
  logger.error(f"Failed to delete blob {blob_name}", error=str(e))
72
  return False
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  # Singleton instance
76
  blob_storage = AzureBlobStorage()
 
57
  logger.error(f"Failed to download blob {blob_name}", error=str(e))
58
  raise
59
 
60
+ async def upload_bytes(self, content: bytes, blob_name: str) -> str:
61
+ """Upload bytes to Azure Blob Storage using a specific blob name.
62
+
63
+ Unlike upload_file(), this does not generate a UUID name — caller controls the blob_name.
64
+ Used for Parquet files where the name must be deterministic (derived from document_id).
65
+ """
66
+ try:
67
+ async with self._get_blob_client(blob_name) as blob_client:
68
+ logger.info(f"Uploading bytes to blob {blob_name}")
69
+ await blob_client.upload_blob(content, overwrite=True)
70
+ logger.info(f"Successfully uploaded {blob_name}")
71
+ return blob_name
72
+ except Exception as e:
73
+ logger.error(f"Failed to upload bytes to {blob_name}", error=str(e))
74
+ raise
75
+
76
  async def delete_file(self, blob_name: str) -> bool:
77
  """Delete file from Azure Blob Storage."""
78
  try:
 
87
  logger.error(f"Failed to delete blob {blob_name}", error=str(e))
88
  return False
89
 
90
+ async def delete_blobs_with_prefix(self, prefix: str) -> int:
91
+ """Delete all blobs whose name starts with prefix. Returns count deleted.
92
+
93
+ Used to delete all Parquet files for a document in one call.
94
+ """
95
+ from azure.storage.blob.aio import ContainerClient
96
+ container_url = f"{self.account_url}/{self.container_name}?{self.sas_token}"
97
+ deleted = 0
98
+ try:
99
+ async with ContainerClient.from_container_url(container_url) as container:
100
+ async for blob in container.list_blobs(name_starts_with=prefix):
101
+ await container.delete_blob(blob.name)
102
+ deleted += 1
103
+ logger.info(f"Deleted {deleted} blobs with prefix {prefix}")
104
+ except Exception as e:
105
+ logger.error(f"Failed to delete blobs with prefix {prefix}", error=str(e))
106
+ return deleted
107
+
108
 
109
  # Singleton instance
110
  blob_storage = AzureBlobStorage()
uv.lock CHANGED
@@ -66,6 +66,7 @@ dependencies = [
66
  { name = "spacy" },
67
  { name = "sqlalchemy", extra = ["asyncio"] },
68
  { name = "sqlalchemy-bigquery" },
 
69
  { name = "sse-starlette" },
70
  { name = "starlette" },
71
  { name = "structlog" },
@@ -149,6 +150,7 @@ requires-dist = [
149
  { name = "spacy", specifier = "==3.8.3" },
150
  { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.36" },
151
  { name = "sqlalchemy-bigquery", specifier = ">=1.11.0" },
 
152
  { name = "sse-starlette", specifier = "==2.1.3" },
153
  { name = "starlette", specifier = "==0.41.3" },
154
  { name = "structlog", specifier = "==24.4.0" },
@@ -3221,6 +3223,15 @@ wheels = [
3221
  { url = "https://files.pythonhosted.org/packages/c0/87/11e6de00ef7949bb8ea06b55304a1a4911c329fdf0d9882b464db240c2c5/sqlalchemy_bigquery-1.16.0-py3-none-any.whl", hash = "sha256:0fe7634cd954f3e74f5e2db6d159f9e5ee87a47fbe8d52eac3cd3bb3dadb3a77", size = 40615, upload-time = "2025-11-06T01:35:39.358Z" },
3222
  ]
3223
 
 
 
 
 
 
 
 
 
 
3224
  [[package]]
3225
  name = "srsly"
3226
  version = "2.5.3"
 
66
  { name = "spacy" },
67
  { name = "sqlalchemy", extra = ["asyncio"] },
68
  { name = "sqlalchemy-bigquery" },
69
+ { name = "sqlglot" },
70
  { name = "sse-starlette" },
71
  { name = "starlette" },
72
  { name = "structlog" },
 
150
  { name = "spacy", specifier = "==3.8.3" },
151
  { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.36" },
152
  { name = "sqlalchemy-bigquery", specifier = ">=1.11.0" },
153
+ { name = "sqlglot", specifier = ">=25.0.0" },
154
  { name = "sse-starlette", specifier = "==2.1.3" },
155
  { name = "starlette", specifier = "==0.41.3" },
156
  { name = "structlog", specifier = "==24.4.0" },
 
3223
  { url = "https://files.pythonhosted.org/packages/c0/87/11e6de00ef7949bb8ea06b55304a1a4911c329fdf0d9882b464db240c2c5/sqlalchemy_bigquery-1.16.0-py3-none-any.whl", hash = "sha256:0fe7634cd954f3e74f5e2db6d159f9e5ee87a47fbe8d52eac3cd3bb3dadb3a77", size = 40615, upload-time = "2025-11-06T01:35:39.358Z" },
3224
  ]
3225
 
3226
+ [[package]]
3227
+ name = "sqlglot"
3228
+ version = "30.6.0"
3229
+ source = { registry = "https://pypi.org/simple" }
3230
+ sdist = { url = "https://files.pythonhosted.org/packages/3c/66/6ece15f197874e56c76e1d0269cebf284ba992a80dfadca9d1972fdf7edf/sqlglot-30.6.0.tar.gz", hash = "sha256:246d34d39927422a50a3fa155f37b2f6346fba85f1a755b13c941eb32ef93361", size = 5835307, upload-time = "2026-04-20T20:11:08.164Z" }
3231
+ wheels = [
3232
+ { url = "https://files.pythonhosted.org/packages/dc/e7/64fe971cbca33a0446b06f4a5ff8e3fa4a1dbd0a039ceabcc3e6cf4087a9/sqlglot-30.6.0-py3-none-any.whl", hash = "sha256:e005fc2f47994f90d7d8df341f1cbe937518497b0b7b1507d4c03c4c9dfd2778", size = 673920, upload-time = "2026-04-20T20:11:05.758Z" },
3233
+ ]
3234
+
3235
  [[package]]
3236
  name = "srsly"
3237
  version = "2.5.3"