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 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/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()