Rifqi Hafizuddin commited on
Commit ·
767625e
1
Parent(s): 0707f2b
[KM-436-437] edit knowledge handler pipeline
Browse files- src/db/postgres/init_db.py +35 -0
- src/document/document_service.py +17 -1
- src/knowledge/parquet_service.py +77 -0
- src/knowledge/processing_service.py +21 -7
- src/models/sql_query.py +8 -0
- src/models/structured_output.py +4 -0
- src/pipeline/db_pipeline/db_pipeline_service.py +35 -18
- src/pipeline/document_pipeline/document_pipeline.py +5 -1
- src/storage/az_blob/az_blob.py +34 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 197 |
-
|
| 198 |
-
schema = await asyncio.to_thread(get_schema, engine, exclude_tables)
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 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/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()
|