minh-4T commited on
Commit
f9006d9
·
1 Parent(s): 638079e

update process document

Browse files
README.md CHANGED
@@ -44,24 +44,23 @@ Khi server bat dau, `lifespan` trong `main.py` chay theo thu tu:
44
  1. Doc bien moi truong tu `core/config.py`.
45
  2. Tao pool ket noi PostgreSQL (`asyncpg`) va dam bao bang `history` ton tai.
46
  3. Ket noi Qdrant Cloud.
47
- 4. Neu collection `quy_che_db` chua ton tai: build vectorstore moi bang `core/vectorstore.py`.
48
- 5. Neu da ton tai: tai vectorstore va chunks da luu.
49
- 6. Khoi tao `HybridRetriever` trong `core/retriever.py`.
50
  7. Danh dau app san sang (endpoint `/healthz` se bao `ready=true`).
51
 
52
- ## 3) Luong ingest tai lieu (xay dung vectorstore)
53
 
54
- Luong nay nam trong `core/vectorstore.py`:
55
 
56
- 1. Quet de quy file trong thu muc `data/` (`.pdf`, `.doc`, `.docx`), bao gom ca cac thu muc nam hoc nhu `So tay sinh vien 2022-2023/`.
57
- 2. Trich xuat noi dung (giu bang tu PDF/DOCX).
58
- 3. Lam sach text bang `core/text_utils.py`.
59
- 4. Gan metadata `academic_year` cho tung tai lieu/chunk (neu tim thay mau nam hoc `YYYY-YYYY` trong duong dan hoac ten file).
60
- 5. Chunk van ban thong minh bang `core/chunking.py`.
61
  6. Embedding chunks bang model trong `core/models.py`.
62
- 7. Day vector len Qdrant collection `quy_che_db`.
63
- 8. Luu ban sao chunks local vao `vectorstore/chunks.pkl` de startup nhanh hon.
64
- 9. Neu phat hien file moi trong `data/` ma chunks cache chua co, he thong tu dong rebuild de dong bo du lieu.
65
 
66
  ## 3.1) Hoi va tra loi theo nam hoc
67
 
@@ -120,7 +119,7 @@ Tuong tu luong `/chat`, khac o cho:
120
 
121
  ### Du lieu va vector
122
 
123
- - `core/vectorstore.py`: Load tai lieu, tien xu ly, chunking, embedding, tao/tai Qdrant vector store, luu chunks local.
124
  - `core/chunking.py`: Cat van ban thong minh (uu tien giu cau truc bang/danh sach).
125
  - `core/text_utils.py`: Lam sach va chuan hoa noi dung text truoc khi embedding.
126
  - `core/models.py`: Khoi tao embedding model va cross-encoder model.
@@ -176,4 +175,4 @@ Sau khi chay, kiem tra:
176
  ## 9) Ghi chu
177
 
178
  - Trong Hugging Face Spaces, frontmatter o dau file README can duoc giu nguyen.
179
- - Lan chay dau co the cham do qua trinh doc tai lieu, chunk, embedding va day vector len Qdrant.
 
44
  1. Doc bien moi truong tu `core/config.py`.
45
  2. Tao pool ket noi PostgreSQL (`asyncpg`) va dam bao bang `history` ton tai.
46
  3. Ket noi Qdrant Cloud.
47
+ 4. Khoi tao `CollectionRouterRetriever` de tim theo cac collection dang active tren Qdrant.
48
+ 5. Khoi tao Supabase sync coordinator va chay `startup:initial_sync` thong qua `build_vectorstore_improved` trong `core/vectorstore.py` (co the cho toi da theo `SUPABASE_STARTUP_SYNC_WAIT_SECONDS` hoac chay nen).
49
+ 6. Bat polling sync dinh ky de dong bo thay doi add/update/delete tu Supabase.
50
  7. Danh dau app san sang (endpoint `/healthz` se bao `ready=true`).
51
 
52
+ ## 3) Luong ingest tai lieu (Supabase-only)
53
 
54
+ Luong nay duoc kich hoat boi scheduler/event sync trong `core/supabase_sync_service.py` va ingest trong `core/document_ingest_service.py`:
55
 
56
+ 1. Lay danh sach object tu Supabase Storage va diff voi snapshot de xac dinh `added/updated/deleted`.
57
+ 2. Download tung file can ingest ve file tam.
58
+ 3. Trich xuat noi dung tai lieu bang bo ham cu (`load_documents_from_file` trong `core/vectorstore.py`).
59
+ 4. Lam sach text bang bo ham cu (`clean_text` trong `core/text_utils.py`).
60
+ 5. Chunk van ban bang bo ham cu (`smart_chunking` trong `core/chunking.py`).
61
  6. Embedding chunks bang model trong `core/models.py`.
62
+ 7. Upsert vector len Qdrant collection theo folder nam hoc.
63
+ 8. Xoa/ghi de theo `object_path` de dam bao incremental sync va tranh duplicate.
 
64
 
65
  ## 3.1) Hoi va tra loi theo nam hoc
66
 
 
119
 
120
  ### Du lieu va vector
121
 
122
+ - `core/vectorstore.py`: Cung cap bo ham xu ly tai lieu cu (doc PDF/DOCX, metadata nam hoc) duoc tai su dung trong luong Supabase ingest, dong thoi chua `build_vectorstore_improved`/`load_vectorstore_improved` cho luong Supabase.
123
  - `core/chunking.py`: Cat van ban thong minh (uu tien giu cau truc bang/danh sach).
124
  - `core/text_utils.py`: Lam sach va chuan hoa noi dung text truoc khi embedding.
125
  - `core/models.py`: Khoi tao embedding model va cross-encoder model.
 
175
  ## 9) Ghi chu
176
 
177
  - Trong Hugging Face Spaces, frontmatter o dau file README can duoc giu nguyen.
178
+ - Lan chay dau co the cham do qua trinh initial sync tu Supabase (download + chunk + embedding + upsert Qdrant).
api/admin_documents_router.py DELETED
@@ -1,148 +0,0 @@
1
- import os
2
- import uuid
3
- from typing import Any, Dict, List
4
-
5
- from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Query, UploadFile
6
- from fastapi.concurrency import run_in_threadpool
7
- from sqlalchemy.orm import Session
8
-
9
- from core.config import MAX_UPLOAD_SIZE_MB, UPLOAD_DIR
10
- from core.document_db import Document, get_document_db
11
- from core.document_ingest_service import run_document_ingest_task
12
-
13
- router = APIRouter(prefix="/admin/documents", tags=["admin-documents"])
14
-
15
- _ALLOWED_EXTENSIONS = {".pdf", ".docx", ".txt"}
16
-
17
-
18
- class FileTooLargeError(Exception):
19
- pass
20
-
21
-
22
- def _save_upload_file_stream(file_obj: Any, destination: str, max_size_bytes: int) -> int:
23
- total_size = 0
24
- chunk_size = 1024 * 1024
25
-
26
- with open(destination, "wb") as output:
27
- while True:
28
- chunk = file_obj.read(chunk_size)
29
- if not chunk:
30
- break
31
-
32
- total_size += len(chunk)
33
- if total_size > max_size_bytes:
34
- raise FileTooLargeError("Uploaded file exceeds configured maximum size.")
35
-
36
- output.write(chunk)
37
-
38
- return total_size
39
-
40
-
41
- @router.post("/upload")
42
- async def upload_document(
43
- background_tasks: BackgroundTasks,
44
- file: UploadFile = File(...),
45
- db: Session = Depends(get_document_db),
46
- ) -> Dict[str, Any]:
47
- if not file.filename:
48
- raise HTTPException(status_code=400, detail="File name is required.")
49
-
50
- extension = os.path.splitext(file.filename)[1].lower()
51
- if extension not in _ALLOWED_EXTENSIONS:
52
- raise HTTPException(status_code=400, detail="Unsupported file type. Allowed: .pdf, .docx, .txt")
53
-
54
- os.makedirs(UPLOAD_DIR, exist_ok=True)
55
- stored_name = f"{uuid.uuid4()}{extension}"
56
- stored_path = os.path.abspath(os.path.join(UPLOAD_DIR, stored_name))
57
- max_size_bytes = MAX_UPLOAD_SIZE_MB * 1024 * 1024
58
-
59
- try:
60
- file.file.seek(0)
61
- size = await run_in_threadpool(
62
- _save_upload_file_stream,
63
- file.file,
64
- stored_path,
65
- max_size_bytes,
66
- )
67
- except FileTooLargeError:
68
- if os.path.exists(stored_path):
69
- os.remove(stored_path)
70
- raise HTTPException(
71
- status_code=413,
72
- detail=f"File is too large. Max allowed size is {MAX_UPLOAD_SIZE_MB} MB.",
73
- )
74
- except Exception as error:
75
- if os.path.exists(stored_path):
76
- os.remove(stored_path)
77
- raise HTTPException(status_code=500, detail=f"Failed to save file: {error}")
78
- finally:
79
- await file.close()
80
-
81
- document = Document(
82
- original_name=file.filename,
83
- stored_name=stored_name,
84
- path=stored_path,
85
- mime_type=file.content_type or "application/octet-stream",
86
- size=size,
87
- status="pending",
88
- total_chunks=0,
89
- )
90
- db.add(document)
91
- db.commit()
92
- db.refresh(document)
93
-
94
- background_tasks.add_task(run_document_ingest_task, document.id)
95
-
96
- return {
97
- "status": "success",
98
- "document_id": document.id,
99
- "original_name": document.original_name,
100
- "stored_name": document.stored_name,
101
- "path": document.path,
102
- }
103
-
104
-
105
- @router.get("/status/{document_id}")
106
- def get_document_status(document_id: str, db: Session = Depends(get_document_db)) -> Dict[str, Any]:
107
- document = db.query(Document).filter(Document.id == document_id).first()
108
- if document is None:
109
- raise HTTPException(status_code=404, detail="Document not found.")
110
-
111
- return {
112
- "status": "success",
113
- "document_id": document.id,
114
- "processing_status": document.status,
115
- "total_chunks": document.total_chunks,
116
- "error_message": document.error_message,
117
- "created_at": document.created_at,
118
- }
119
-
120
-
121
- @router.get("")
122
- def list_documents(
123
- limit: int = Query(default=20, ge=1, le=100),
124
- offset: int = Query(default=0, ge=0),
125
- db: Session = Depends(get_document_db),
126
- ) -> Dict[str, Any]:
127
- records = (
128
- db.query(Document)
129
- .order_by(Document.created_at.desc())
130
- .offset(offset)
131
- .limit(limit)
132
- .all()
133
- )
134
-
135
- return {
136
- "status": "success",
137
- "items": [
138
- {
139
- "id": doc.id,
140
- "original_name": doc.original_name,
141
- "stored_name": doc.stored_name,
142
- "status": doc.status,
143
- "total_chunks": doc.total_chunks,
144
- "created_at": doc.created_at,
145
- }
146
- for doc in records
147
- ],
148
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
core/config.py CHANGED
@@ -18,12 +18,6 @@ def _is_hf_persistent_storage_available() -> bool:
18
  _USE_HF_PERSISTENT_STORAGE = _is_hf_persistent_storage_available()
19
 
20
 
21
- def _default_upload_dir() -> str:
22
- if _USE_HF_PERSISTENT_STORAGE:
23
- return '/data/uploads'
24
- return 'uploads'
25
-
26
-
27
  def _default_documents_db_url() -> str:
28
  if _USE_HF_PERSISTENT_STORAGE:
29
  return 'sqlite:////data/rag_metadata.db'
@@ -54,10 +48,6 @@ CHUNK_OVERLAP = int(os.getenv('CHUNK_OVERLAP', '150'))
54
  TOP_K_RESULTS = int(os.getenv('TOP_K_RESULTS', '10'))
55
  FINAL_TOP_K = int(os.getenv('FINAL_TOP_K', '5'))
56
 
57
- DATA_DIR = os.getenv('DATA_DIR', 'data')
58
- VECTOR_DIR = os.getenv('VECTOR_DIR', 'vectorstore')
59
- UPLOAD_DIR = os.getenv('UPLOAD_DIR', _default_upload_dir())
60
- MAX_UPLOAD_SIZE_MB = int(os.getenv('MAX_UPLOAD_SIZE_MB', '20'))
61
  QDRANT_COLLECTION = os.getenv('QDRANT_COLLECTION', 'rag_docs')
62
  DOCUMENTS_DATABASE_URL = os.getenv('DOCUMENTS_DATABASE_URL', _default_documents_db_url())
63
 
 
18
  _USE_HF_PERSISTENT_STORAGE = _is_hf_persistent_storage_available()
19
 
20
 
 
 
 
 
 
 
21
  def _default_documents_db_url() -> str:
22
  if _USE_HF_PERSISTENT_STORAGE:
23
  return 'sqlite:////data/rag_metadata.db'
 
48
  TOP_K_RESULTS = int(os.getenv('TOP_K_RESULTS', '10'))
49
  FINAL_TOP_K = int(os.getenv('FINAL_TOP_K', '5'))
50
 
 
 
 
 
51
  QDRANT_COLLECTION = os.getenv('QDRANT_COLLECTION', 'rag_docs')
52
  DOCUMENTS_DATABASE_URL = os.getenv('DOCUMENTS_DATABASE_URL', _default_documents_db_url())
53
 
core/document_ingest_service.py CHANGED
@@ -1,13 +1,10 @@
1
  import logging
2
  import os
3
- import re
4
  import uuid
5
  from datetime import datetime, timezone
6
  from typing import List, Optional
7
 
8
- from docx import Document as DocxDocument
9
- from fastapi.concurrency import run_in_threadpool
10
- from pypdf import PdfReader
11
  from qdrant_client import QdrantClient
12
  from qdrant_client.http.exceptions import UnexpectedResponse
13
  from qdrant_client.models import (
@@ -20,78 +17,71 @@ from qdrant_client.models import (
20
  VectorParams,
21
  )
22
 
23
- from .config import CHUNK_OVERLAP, CHUNK_SIZE, QDRANT_API_KEY, QDRANT_COLLECTION, QDRANT_URL
 
24
  from .document_db import Document, DocumentChunk, SessionLocal
25
  from .models import embeddings
 
 
26
 
27
  logger = logging.getLogger(__name__)
28
 
29
  _ALLOWED_EXTENSIONS = {".pdf", ".docx", ".txt"}
30
- _WHITESPACE_RE = re.compile(r"\s+")
31
- _TOKEN_RE = re.compile(r"\S+")
32
 
33
 
34
- def normalize_text(text: str) -> str:
35
- if not text:
36
- return ""
37
-
38
- cleaned = text.replace("\x00", " ")
39
- cleaned = cleaned.replace("\ufeff", " ")
40
- cleaned = cleaned.replace("\u200b", " ").replace("\u200c", " ").replace("\u200d", " ")
41
- cleaned = _WHITESPACE_RE.sub(" ", cleaned)
42
- return cleaned.strip()
43
-
44
-
45
- def read_document_content(path: str, extension: str) -> str:
46
  extension = extension.lower()
47
  if extension not in _ALLOWED_EXTENSIONS:
48
  raise ValueError(f"Unsupported file extension: {extension}")
49
 
50
- if extension == ".pdf":
51
- reader = PdfReader(path)
52
- page_texts = [(page.extract_text() or "") for page in reader.pages]
53
- return "\n".join(page_texts)
54
 
55
- if extension == ".docx":
56
- doc = DocxDocument(path)
57
- paragraphs = [p.text for p in doc.paragraphs if p.text]
58
 
59
- for table in doc.tables:
60
- for row in table.rows:
61
- row_cells = [cell.text.strip() for cell in row.cells]
62
- if any(row_cells):
63
- paragraphs.append(" | ".join(row_cells))
64
 
65
- return "\n".join(paragraphs)
 
 
 
66
 
67
- with open(path, "r", encoding="utf-8", errors="ignore") as file:
68
- return file.read()
 
 
69
 
 
 
70
 
71
- def chunk_text_by_tokens(text: str, chunk_size: int, overlap: int) -> List[str]:
72
- if chunk_size <= 0:
73
- raise ValueError("CHUNK_SIZE must be > 0")
74
- if overlap < 0:
75
- raise ValueError("CHUNK_OVERLAP must be >= 0")
76
- if overlap >= chunk_size:
77
- raise ValueError("CHUNK_OVERLAP must be smaller than CHUNK_SIZE")
78
 
79
- tokens = _TOKEN_RE.findall(text)
80
- if not tokens:
81
- return []
82
 
83
- step = chunk_size - overlap
84
- chunks: List[str] = []
85
 
86
- for start in range(0, len(tokens), step):
87
- end = min(start + chunk_size, len(tokens))
88
- piece = " ".join(tokens[start:end]).strip()
89
- if piece:
90
- chunks.append(piece)
91
- if end >= len(tokens):
92
- break
 
 
 
 
 
 
 
 
 
 
93
 
94
- return chunks
95
 
96
 
97
  def _parse_datetime(value: Optional[str]):
@@ -193,6 +183,7 @@ def process_document_ingest(
193
 
194
  effective_file_path = (file_path or "").strip()
195
  effective_source_path = (source_path or "").strip()
 
196
 
197
  try:
198
  document = db.query(Document).filter(Document.id == document_id).first()
@@ -204,18 +195,25 @@ def process_document_ingest(
204
  document.error_message = None
205
  db.commit()
206
 
207
- ingest_file_path = effective_file_path or document.path
208
- if not ingest_file_path:
209
- raise ValueError("Document file path is missing for ingest.")
 
210
 
211
- source_object_ref = (source_object_path or document.object_path or "").strip() or None
212
 
213
  extension_source = source_object_ref or document.stored_name or ingest_file_path
214
  _, extension = os.path.splitext(extension_source)
215
 
216
- raw_text = read_document_content(ingest_file_path, extension)
217
- normalized = normalize_text(raw_text)
218
- chunks = chunk_text_by_tokens(normalized, CHUNK_SIZE, CHUNK_OVERLAP)
 
 
 
 
 
 
219
 
220
  if not chunks:
221
  raise ValueError("Document has no readable content after normalization.")
@@ -240,7 +238,9 @@ def process_document_ingest(
240
  points: List[PointStruct] = []
241
  db_chunk_rows: List[DocumentChunk] = []
242
 
243
- for index, (chunk_text, vector) in enumerate(zip(chunks, vectors)):
 
 
244
  point_id = str(uuid.uuid4())
245
  payload = {
246
  "document_id": document.id,
@@ -250,6 +250,10 @@ def process_document_ingest(
250
  "object_path": source_object_ref,
251
  "folder_key": document.folder_key,
252
  "collection_name": target_collection,
 
 
 
 
253
  "source_updated_at": source_updated_at,
254
  "source_etag": source_etag,
255
  "chunk_index": index,
@@ -362,8 +366,3 @@ def delete_vectors_for_object_path(collection_name: str, object_path: str) -> bo
362
  )
363
 
364
  return True
365
-
366
-
367
- async def run_document_ingest_task(document_id: str) -> None:
368
- # Heavy ingest work runs in threadpool to keep event loop responsive.
369
- await run_in_threadpool(process_document_ingest, document_id)
 
1
  import logging
2
  import os
 
3
  import uuid
4
  from datetime import datetime, timezone
5
  from typing import List, Optional
6
 
7
+ from langchain_core.documents import Document as LangChainDocument
 
 
8
  from qdrant_client import QdrantClient
9
  from qdrant_client.http.exceptions import UnexpectedResponse
10
  from qdrant_client.models import (
 
17
  VectorParams,
18
  )
19
 
20
+ from .chunking import smart_chunking
21
+ from .config import QDRANT_API_KEY, QDRANT_COLLECTION, QDRANT_URL
22
  from .document_db import Document, DocumentChunk, SessionLocal
23
  from .models import embeddings
24
+ from .text_utils import clean_text
25
+ from .vectorstore import extract_academic_year, load_documents_from_file
26
 
27
  logger = logging.getLogger(__name__)
28
 
29
  _ALLOWED_EXTENSIONS = {".pdf", ".docx", ".txt"}
 
 
30
 
31
 
32
+ def _load_documents_for_ingest(path: str, extension: str) -> List[LangChainDocument]:
 
 
 
 
 
 
 
 
 
 
 
33
  extension = extension.lower()
34
  if extension not in _ALLOWED_EXTENSIONS:
35
  raise ValueError(f"Unsupported file extension: {extension}")
36
 
37
+ return load_documents_from_file(path, os.path.basename(path))
 
 
 
38
 
 
 
 
39
 
40
+ def _clean_documents_for_ingest(docs: List[LangChainDocument], source_name: str) -> List[LangChainDocument]:
41
+ cleaned_docs: List[LangChainDocument] = []
 
 
 
42
 
43
+ for index, doc in enumerate(docs, 1):
44
+ cleaned = clean_text(doc.page_content)
45
+ if not cleaned or len(cleaned.split()) < 20:
46
+ continue
47
 
48
+ metadata = doc.metadata.copy() if isinstance(doc.metadata, dict) else {}
49
+ page_number = metadata.get("page")
50
+ if page_number is None:
51
+ page_number = index
52
 
53
+ metadata["source_file"] = source_name
54
+ metadata["page_number"] = page_number
55
 
56
+ cleaned_docs.append(
57
+ LangChainDocument(
58
+ page_content=cleaned,
59
+ metadata=metadata,
60
+ )
61
+ )
 
62
 
63
+ return cleaned_docs
 
 
64
 
 
 
65
 
66
+ def chunk_documents_for_ingest(
67
+ path: str,
68
+ extension: str,
69
+ source_name: str,
70
+ source_relpath: str,
71
+ ) -> List[LangChainDocument]:
72
+ loaded_docs = _load_documents_for_ingest(path, extension)
73
+ cleaned_docs = _clean_documents_for_ingest(loaded_docs, source_name)
74
+ if not cleaned_docs:
75
+ return []
76
+
77
+ academic_year = extract_academic_year(source_relpath) or "ALL"
78
+ for doc in cleaned_docs:
79
+ metadata = doc.metadata.copy() if isinstance(doc.metadata, dict) else {}
80
+ metadata["source_relpath"] = source_relpath
81
+ metadata["academic_year"] = academic_year
82
+ doc.metadata = metadata
83
 
84
+ return [doc for doc in smart_chunking(cleaned_docs) if (doc.page_content or "").strip()]
85
 
86
 
87
  def _parse_datetime(value: Optional[str]):
 
183
 
184
  effective_file_path = (file_path or "").strip()
185
  effective_source_path = (source_path or "").strip()
186
+ source_object_ref = (source_object_path or "").strip()
187
 
188
  try:
189
  document = db.query(Document).filter(Document.id == document_id).first()
 
195
  document.error_message = None
196
  db.commit()
197
 
198
+ if not effective_file_path:
199
+ raise ValueError("Supabase-only ingest requires downloaded file_path.")
200
+ if not source_object_ref:
201
+ raise ValueError("Supabase-only ingest requires source_object_path.")
202
 
203
+ ingest_file_path = effective_file_path
204
 
205
  extension_source = source_object_ref or document.stored_name or ingest_file_path
206
  _, extension = os.path.splitext(extension_source)
207
 
208
+ source_name = os.path.basename(source_object_ref or document.stored_name or ingest_file_path)
209
+ source_relpath = source_object_ref or source_name
210
+ chunk_docs = chunk_documents_for_ingest(
211
+ path=ingest_file_path,
212
+ extension=extension,
213
+ source_name=source_name,
214
+ source_relpath=source_relpath,
215
+ )
216
+ chunks = [doc.page_content for doc in chunk_docs]
217
 
218
  if not chunks:
219
  raise ValueError("Document has no readable content after normalization.")
 
238
  points: List[PointStruct] = []
239
  db_chunk_rows: List[DocumentChunk] = []
240
 
241
+ for index, (chunk_doc, vector) in enumerate(zip(chunk_docs, vectors)):
242
+ chunk_text = chunk_doc.page_content
243
+ metadata = chunk_doc.metadata if isinstance(chunk_doc.metadata, dict) else {}
244
  point_id = str(uuid.uuid4())
245
  payload = {
246
  "document_id": document.id,
 
250
  "object_path": source_object_ref,
251
  "folder_key": document.folder_key,
252
  "collection_name": target_collection,
253
+ "source_file": metadata.get("source_file") or source_name,
254
+ "source_relpath": metadata.get("source_relpath") or source_relpath,
255
+ "academic_year": metadata.get("academic_year") or "ALL",
256
+ "page_number": metadata.get("page_number"),
257
  "source_updated_at": source_updated_at,
258
  "source_etag": source_etag,
259
  "chunk_index": index,
 
366
  )
367
 
368
  return True
 
 
 
 
 
core/vectorstore.py CHANGED
@@ -1,33 +1,22 @@
 
 
1
  import os
2
  import re
3
- from typing import List, Tuple
4
- from langchain_qdrant import QdrantVectorStore
5
- from qdrant_client import QdrantClient
6
- from qdrant_client.http.models import Distance, VectorParams
7
- from docx import Document
8
- from .models import embeddings
9
- from .text_utils import clean_text
10
- from .chunking import smart_chunking
11
- from .config import DATA_DIR, VECTOR_DIR, QDRANT_API_KEY, QDRANT_URL, QDRANT_COLLECTION
12
- from langchain_core.documents import Document as LangChainDocument
13
- import zipfile
14
- import xml.etree.ElementTree as ET
15
- import pickle
16
  import pdfplumber
 
17
  from docx.document import Document as _Document
18
- from docx.oxml.text.paragraph import CT_P
19
  from docx.oxml.table import CT_Tbl
20
- from docx.table import _Cell, Table
 
21
  from docx.text.paragraph import Paragraph
22
- import logging
 
 
23
 
24
- logging.basicConfig(level=logging.INFO)
25
  logger = logging.getLogger(__name__)
26
 
27
- CHUNKS_PICKLE = os.path.join(VECTOR_DIR, "chunks.pkl")
28
- COLLECTION_NAME = QDRANT_COLLECTION
29
- # [YEAR-AWARE CHANGE] Ho tro quet de quy va gan metadata nam hoc.
30
- SUPPORTED_FORMATS = ('.pdf', '.doc', '.docx')
31
  ACADEMIC_YEAR_PATTERN = re.compile(r"(20\d{2})\s*[-_]\s*(20\d{2})")
32
 
33
 
@@ -44,143 +33,52 @@ def extract_academic_year(text: str) -> str:
44
  return normalize_academic_year(match.group(1), match.group(2))
45
 
46
 
47
- def discover_data_files() -> List[Tuple[str, str, str, str]]:
48
- """Quet de quy thu muc data va tra ve (filepath, filename, relpath, academic_year)."""
49
- if not os.path.isdir(DATA_DIR):
50
- return []
51
-
52
- discovered = []
53
- for root, _, files in os.walk(DATA_DIR):
54
- for filename in files:
55
- if not filename.lower().endswith(SUPPORTED_FORMATS):
56
- continue
57
-
58
- filepath = os.path.join(root, filename)
59
- relpath = os.path.relpath(filepath, DATA_DIR)
60
- year = extract_academic_year(relpath) or "ALL"
61
- discovered.append((filepath, filename, relpath, year))
62
-
63
- discovered.sort(key=lambda x: x[2].lower())
64
- return discovered
65
-
66
-
67
- def collect_chunk_relpaths(chunks: List) -> set:
68
- relpaths = set()
69
- for chunk in chunks:
70
- metadata = chunk.metadata if isinstance(chunk.metadata, dict) else {}
71
- relpath = metadata.get("source_relpath")
72
- if relpath:
73
- relpaths.add(os.path.normpath(str(relpath)))
74
- return relpaths
75
-
76
-
77
- def enrich_chunk_metadata(chunks: List) -> bool:
78
- """Bo sung metadata nam hoc cho chunks cu de dam bao loc theo nam hoat dong."""
79
- changed = False
80
- for chunk in chunks:
81
- metadata = chunk.metadata if isinstance(chunk.metadata, dict) else {}
82
-
83
- source = metadata.get("source")
84
- source_file = metadata.get("source_file")
85
- source_relpath = metadata.get("source_relpath")
86
-
87
- if not source_relpath:
88
- if source and os.path.isabs(str(source)):
89
- try:
90
- source_relpath = os.path.relpath(source, DATA_DIR)
91
- except Exception:
92
- source_relpath = str(source)
93
- elif source:
94
- source_relpath = str(source)
95
- elif source_file:
96
- source_relpath = str(source_file)
97
-
98
- if source_relpath:
99
- metadata["source_relpath"] = source_relpath
100
- changed = True
101
-
102
- if not metadata.get("academic_year"):
103
- year = extract_academic_year(source_relpath or source_file or "") or "ALL"
104
- metadata["academic_year"] = year
105
- changed = True
106
-
107
- if "page_number" not in metadata and metadata.get("page") is not None:
108
- metadata["page_number"] = metadata.get("page")
109
- changed = True
110
-
111
- chunk.metadata = metadata
112
-
113
- return changed
114
-
115
-
116
- # [YEAR-AWARE CHANGE] Gom doc tu toan bo thu muc data theo cau truc nam hoc.
117
- def load_and_clean_all_docs() -> List[LangChainDocument]:
118
- docs: List[LangChainDocument] = []
119
- file_entries = discover_data_files()
120
-
121
- if not file_entries:
122
- logger.error(" Không tìm thấy file PDF, DOC, hoặc DOCX!")
123
- return docs
124
-
125
- for filepath, filename, relpath, academic_year in file_entries:
126
- logger.info(f" Đang đọc: {relpath}")
127
- loaded_docs = load_documents_from_file(filepath, filename)
128
-
129
- for i, doc in enumerate(loaded_docs, 1):
130
- cleaned = clean_text(doc.page_content)
131
- if not cleaned or len(cleaned.split()) < 20:
132
- continue
133
-
134
- page_number = doc.metadata.get("page") if isinstance(doc.metadata, dict) else None
135
- if page_number is None:
136
- page_number = i
137
-
138
- doc.metadata["source_file"] = filename
139
- doc.metadata["source_relpath"] = relpath
140
- doc.metadata["academic_year"] = academic_year
141
- doc.metadata["page_number"] = page_number
142
- doc.page_content = cleaned
143
- docs.append(doc)
144
-
145
- return docs
146
-
147
  def table_to_markdown(data: List[List[str]]) -> str:
148
  if not data or len(data) < 2:
149
  return ""
150
- header = data[0]
151
- header = [str(cell).replace('\n', ' ').strip() if cell else "" for cell in header]
152
  separator = ["---"] * len(header)
153
- markdown_lines = []
154
- markdown_lines.append("| " + " | ".join(header) + " |")
155
- markdown_lines.append("| " + " | ".join(separator) + " |")
 
 
156
  for row in data[1:]:
157
- clean_row = [str(cell).replace('\n', '<br>').strip() if cell else "" for cell in row]
158
  markdown_lines.append("| " + " | ".join(clean_row) + " |")
 
159
  return "\n".join(markdown_lines) + "\n\n"
160
 
 
161
  def read_pdf_with_tables(filepath: str) -> List[LangChainDocument]:
162
- docs = []
163
  try:
164
  with pdfplumber.open(filepath) as pdf:
165
- for i, page in enumerate(pdf.pages, 1):
166
  text = page.extract_text() or ""
167
  tables = page.extract_tables()
168
- table_texts = []
169
  if tables:
170
  for table in tables:
171
  md_table = table_to_markdown(table)
172
  if md_table:
173
  table_texts.append(md_table)
174
- full_content = text + "\n\n[BẢNG DỮ LIỆU TRÍCH XUẤT]:\n" + "\n".join(table_texts)
 
175
  if full_content.strip():
176
- docs.append(LangChainDocument(
177
- page_content=full_content,
178
- metadata={"source": filepath, "page": i}
179
- ))
180
- except Exception as e:
181
- logger.error(f"Lỗi đọc PDF (pdfplumber) {os.path.basename(filepath)}: {e}")
 
 
 
182
  return docs
183
 
 
184
  def iter_block_items(parent):
185
  if isinstance(parent, _Document):
186
  parent_elm = parent.element.body
@@ -194,206 +92,103 @@ def iter_block_items(parent):
194
  elif isinstance(child, CT_Tbl):
195
  yield Table(child, parent)
196
 
 
197
  def read_docx_with_tables(filepath: str) -> str:
198
  doc = Document(filepath)
199
- full_text = []
200
  for block in iter_block_items(doc):
201
  if isinstance(block, Paragraph):
202
  if block.text.strip():
203
  full_text.append(block.text.strip())
204
  elif isinstance(block, Table):
205
- table_data = []
206
  for row in block.rows:
207
- row_data = []
208
  for cell in row.cells:
209
- cell_text = clean_text(cell.text)
210
- row_data.append(cell_text)
211
  table_data.append(row_data)
 
212
  md_table = table_to_markdown(table_data)
213
  if md_table:
214
  full_text.append(f"\n{md_table}\n")
 
215
  return "\n".join(full_text)
216
 
217
- def extract_text_from_doc_com(filepath: str) -> str:
218
- try:
219
- import win32com.client
220
- word = win32com.client.Dispatch("Word.Application")
221
- word.Visible = False
222
- doc = word.Documents.Open(os.path.abspath(filepath))
223
- text = doc.Range().Text
224
- doc.Close()
225
- word.Quit()
226
- return text.strip()
227
- except Exception as e:
228
- logger.error(f" COM API lỗi: {str(e)[:40]}")
229
- return ""
230
 
231
- def extract_text_from_doc(filepath: str) -> str:
232
- try:
233
- doc = Document(filepath)
234
- text = "\n".join([para.text for para in doc.paragraphs])
235
- if text.strip():
236
- return text
237
- except Exception as e:
238
- logger.error(f"Lỗi đọc DOC {os.path.basename(filepath)}: {e}")
239
- try:
240
- with zipfile.ZipFile(filepath, 'r') as zip_ref:
241
- xml_content = zip_ref.read('word/document.xml')
242
- root = ET.fromstring(xml_content)
243
- ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
244
- paragraphs = root.findall('.//w:p', ns)
245
- text_list = []
246
- for para in paragraphs:
247
- texts = para.findall('.//w:t', ns)
248
- para_text = ''.join([t.text for t in texts if t.text])
249
- if para_text.strip():
250
- text_list.append(para_text)
251
- return "\n".join(text_list)
252
- except Exception as e:
253
- logger.error(f" Lỗi đọc DOCX {os.path.basename(filepath)}: {e}")
254
- pass
255
- if filepath.lower().endswith('.doc'):
256
- return extract_text_from_doc_com(filepath)
257
- return ""
258
-
259
- def load_doc_file(filepath: str) -> List[LangChainDocument]:
260
- docs = []
261
- try:
262
- text = extract_text_from_doc(filepath)
263
- if text.strip():
264
- docs.append(LangChainDocument(page_content=text, metadata={"source": filepath}))
265
- else:
266
- logger.warning(f" File rỗng: {os.path.basename(filepath)}")
267
- except Exception as e:
268
- logger.error(f" Không thể đọc {os.path.basename(filepath)}: {str(e)[:60]}")
269
- return docs
270
 
271
- def load_documents_from_file(filepath: str, filename: str) -> List:
272
- docs = []
273
  try:
274
- if filename.lower().endswith('.pdf'):
275
  docs = read_pdf_with_tables(filepath)
276
- elif filename.lower().endswith('.docx'):
277
  text = read_docx_with_tables(filepath)
278
  if text:
279
  docs = [LangChainDocument(page_content=text, metadata={"source": filepath})]
280
- elif filename.lower().endswith('.doc'):
281
- docs = load_doc_file(filepath)
282
-
 
 
 
283
  if docs:
284
- logger.info(f" Đã đọc: {filename}")
 
285
  return docs
286
- except Exception as e:
287
- logger.error(f" Lỗi đọc {filename}: {str(e)[:60]}")
288
  return []
289
 
290
- # [YEAR-AWARE CHANGE] Cho phep tao lai collection khi phat hien file moi.
291
- def build_vectorstore_improved(recreate_collection: bool = False) -> Tuple[QdrantVectorStore, List]:
292
- logger.info(" Đang xây dựng vectorstore...")
293
- docs = load_and_clean_all_docs()
294
-
295
- if not docs:
296
- logger.error(" Không có văn bản hợp lệ!")
297
- return None, []
298
-
299
- logger.info(f" Đã đọc {len(docs)} trang hợp lệ")
300
- chunks = smart_chunking(docs)
301
- logger.info ("Đang kết nối với và đẩy dữ liệu lên Qdrant Cloud ")
302
-
303
- client = QdrantClient(
304
- url=QDRANT_URL,
305
- api_key=QDRANT_API_KEY
306
- )
307
-
308
- if client.collection_exists(COLLECTION_NAME):
309
- if recreate_collection:
310
- logger.warning(f"Collection {COLLECTION_NAME} đã tồn tại. Đang tạo lại để đồng bộ dữ liệu mới...")
311
- client.delete_collection(collection_name=COLLECTION_NAME)
312
- client.create_collection(
313
- collection_name=COLLECTION_NAME,
314
- vectors_config=VectorParams(size=1024, distance=Distance.COSINE)
315
- )
316
- else:
317
- client.create_collection(
318
- collection_name=COLLECTION_NAME,
319
- vectors_config=VectorParams(size=1024, distance=Distance.COSINE)
320
- )
321
 
322
- db = QdrantVectorStore(
323
- client=client,
324
- collection_name=COLLECTION_NAME,
325
- embedding=embeddings,
 
 
 
 
 
 
 
 
 
326
  )
327
- #Đẩy chunks lên cloud
328
- db.add_documents(chunks)
329
 
330
- #Lưu chunk local
 
 
 
 
 
 
331
  try:
332
- os.makedirs(VECTOR_DIR, exist_ok=True)
333
- with open(CHUNKS_PICKLE, 'wb') as f:
334
- pickle.dump(chunks, f)
335
- logger.info(f" Đã lưu chunks vào {CHUNKS_PICKLE}")
336
- except Exception as e:
337
- logger.error(f" Không thể lưu chunks: {e}")
338
-
339
- logger.info(" Hoàn tất xây dựng và đưa lên Qdrant Cloud")
340
- return db, chunks
341
-
342
- def load_vectorstore_improved() -> Tuple[QdrantVectorStore, List]:
343
- logger.info("Đang tải vectorstore t�� Qdrant Cloud")
344
-
345
- client = QdrantClient(
346
- url=QDRANT_URL,
347
- api_key=QDRANT_API_KEY
348
- )
349
- db = QdrantVectorStore(
350
- client=client,
351
- collection_name=COLLECTION_NAME,
352
- embedding=embeddings
353
- )
354
- # Load chunks từ file pickle nếu có, để tránh phải tái tạo từ file nguồn mỗi lần khởi động
355
- if os.path.exists(CHUNKS_PICKLE):
356
- try:
357
- with open(CHUNKS_PICKLE, 'rb') as f:
358
- chunks = pickle.load(f)
359
- if enrich_chunk_metadata(chunks):
360
- try:
361
- os.makedirs(VECTOR_DIR, exist_ok=True)
362
- with open(CHUNKS_PICKLE, 'wb') as f:
363
- pickle.dump(chunks, f)
364
- logger.info(" Đã cập nhật metadata năm học cho chunks local")
365
- except Exception as e:
366
- logger.error(f" Không thể cập nhật {CHUNKS_PICKLE}: {e}")
367
-
368
- # [YEAR-AWARE CHANGE] Neu co file moi theo nam hoc, rebuild de dong bo Qdrant.
369
- discovered_relpaths = {os.path.normpath(relpath) for _, _, relpath, _ in discover_data_files()}
370
- chunk_relpaths = collect_chunk_relpaths(chunks)
371
- missing_relpaths = sorted(discovered_relpaths - chunk_relpaths)
372
-
373
- if missing_relpaths:
374
- logger.warning(
375
- f" Phát hiện {len(missing_relpaths)} file mới chưa có trong chunks cache. Đang build lại vectorstore theo dữ liệu hiện tại..."
376
- )
377
- return build_vectorstore_improved(recreate_collection=True)
378
-
379
- logger.info(f" Đã load {len(chunks)} chunks từ {CHUNKS_PICKLE}")
380
- return db, chunks
381
- except Exception as e:
382
- logger.error(f" Không thể đọc {CHUNKS_PICKLE}: {e} — sẽ thử tái tạo từ file nguồn.")
383
-
384
-
385
- # Nếu mất file pickle hoặc lỗi, fallback về tái tạo từ file nguồn
386
- docs = load_and_clean_all_docs()
387
-
388
- chunks = smart_chunking(docs)
389
- # Lưu lại chunks mới tái tạo vào file pickle để lần sau load nhanh hơn
390
  try:
391
- os.makedirs(VECTOR_DIR, exist_ok=True)
392
- with open(CHUNKS_PICKLE, 'wb') as f:
393
- pickle.dump(chunks, f)
394
- logger.info(f" Đã tái tạo lưu {len(chunks)} chunks vào {CHUNKS_PICKLE}")
395
- except Exception as e:
396
- logger.error(f" Không thể lưu chunks: {e}")
397
-
398
- logger.info(f"Đã tái tạo {len(chunks)} chunks từ file nguồn")
399
- return db, chunks
 
1
+ import asyncio
2
+ import logging
3
  import os
4
  import re
5
+ from typing import Any, Dict, List
6
+
 
 
 
 
 
 
 
 
 
 
 
7
  import pdfplumber
8
+ from docx import Document
9
  from docx.document import Document as _Document
 
10
  from docx.oxml.table import CT_Tbl
11
+ from docx.oxml.text.paragraph import CT_P
12
+ from docx.table import Table, _Cell
13
  from docx.text.paragraph import Paragraph
14
+ from langchain_core.documents import Document as LangChainDocument
15
+
16
+ from .text_utils import clean_text
17
 
 
18
  logger = logging.getLogger(__name__)
19
 
 
 
 
 
20
  ACADEMIC_YEAR_PATTERN = re.compile(r"(20\d{2})\s*[-_]\s*(20\d{2})")
21
 
22
 
 
33
  return normalize_academic_year(match.group(1), match.group(2))
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def table_to_markdown(data: List[List[str]]) -> str:
37
  if not data or len(data) < 2:
38
  return ""
39
+
40
+ header = [str(cell).replace("\n", " ").strip() if cell else "" for cell in data[0]]
41
  separator = ["---"] * len(header)
42
+ markdown_lines = [
43
+ "| " + " | ".join(header) + " |",
44
+ "| " + " | ".join(separator) + " |",
45
+ ]
46
+
47
  for row in data[1:]:
48
+ clean_row = [str(cell).replace("\n", "<br>").strip() if cell else "" for cell in row]
49
  markdown_lines.append("| " + " | ".join(clean_row) + " |")
50
+
51
  return "\n".join(markdown_lines) + "\n\n"
52
 
53
+
54
  def read_pdf_with_tables(filepath: str) -> List[LangChainDocument]:
55
+ docs: List[LangChainDocument] = []
56
  try:
57
  with pdfplumber.open(filepath) as pdf:
58
+ for page_index, page in enumerate(pdf.pages, 1):
59
  text = page.extract_text() or ""
60
  tables = page.extract_tables()
61
+ table_texts: List[str] = []
62
  if tables:
63
  for table in tables:
64
  md_table = table_to_markdown(table)
65
  if md_table:
66
  table_texts.append(md_table)
67
+
68
+ full_content = text + "\n\n[BANG DU LIEU TRICH XUAT]:\n" + "\n".join(table_texts)
69
  if full_content.strip():
70
+ docs.append(
71
+ LangChainDocument(
72
+ page_content=full_content,
73
+ metadata={"source": filepath, "page": page_index},
74
+ )
75
+ )
76
+ except Exception as error:
77
+ logger.error("Loi doc PDF (pdfplumber) %s: %s", os.path.basename(filepath), error)
78
+
79
  return docs
80
 
81
+
82
  def iter_block_items(parent):
83
  if isinstance(parent, _Document):
84
  parent_elm = parent.element.body
 
92
  elif isinstance(child, CT_Tbl):
93
  yield Table(child, parent)
94
 
95
+
96
  def read_docx_with_tables(filepath: str) -> str:
97
  doc = Document(filepath)
98
+ full_text: List[str] = []
99
  for block in iter_block_items(doc):
100
  if isinstance(block, Paragraph):
101
  if block.text.strip():
102
  full_text.append(block.text.strip())
103
  elif isinstance(block, Table):
104
+ table_data: List[List[str]] = []
105
  for row in block.rows:
106
+ row_data: List[str] = []
107
  for cell in row.cells:
108
+ row_data.append(clean_text(cell.text))
 
109
  table_data.append(row_data)
110
+
111
  md_table = table_to_markdown(table_data)
112
  if md_table:
113
  full_text.append(f"\n{md_table}\n")
114
+
115
  return "\n".join(full_text)
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
+ def load_documents_from_file(filepath: str, filename: str) -> List[LangChainDocument]:
119
+ docs: List[LangChainDocument] = []
120
+ lower_name = filename.lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
 
 
122
  try:
123
+ if lower_name.endswith(".pdf"):
124
  docs = read_pdf_with_tables(filepath)
125
+ elif lower_name.endswith(".docx"):
126
  text = read_docx_with_tables(filepath)
127
  if text:
128
  docs = [LangChainDocument(page_content=text, metadata={"source": filepath})]
129
+ elif lower_name.endswith(".txt"):
130
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as input_file:
131
+ text = input_file.read()
132
+ if text and text.strip():
133
+ docs = [LangChainDocument(page_content=text, metadata={"source": filepath})]
134
+
135
  if docs:
136
+ logger.info("Da doc: %s", filename)
137
+
138
  return docs
139
+ except Exception as error:
140
+ logger.error("Loi doc %s: %s", filename, str(error)[:120])
141
  return []
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
+ async def build_vectorstore_improved(
145
+ sync_coordinator: Any,
146
+ startup_wait_seconds: int = 5,
147
+ ) -> Dict[str, Any]:
148
+ """Supabase build step: trigger one initial sync and optionally wait for completion."""
149
+ if sync_coordinator is None:
150
+ raise ValueError("sync_coordinator is required")
151
+
152
+ startup_sync_task = asyncio.create_task(
153
+ sync_coordinator.run_sync(
154
+ trigger="startup:initial_sync",
155
+ queue_if_locked=False,
156
+ )
157
  )
 
 
158
 
159
+ if startup_wait_seconds <= 0:
160
+ return {
161
+ "task": startup_sync_task,
162
+ "initial_sync": None,
163
+ "timed_out": True,
164
+ }
165
+
166
  try:
167
+ initial_sync = await asyncio.wait_for(
168
+ asyncio.shield(startup_sync_task),
169
+ timeout=startup_wait_seconds,
170
+ )
171
+ return {
172
+ "task": startup_sync_task,
173
+ "initial_sync": initial_sync,
174
+ "timed_out": False,
175
+ }
176
+ except asyncio.TimeoutError:
177
+ return {
178
+ "task": startup_sync_task,
179
+ "initial_sync": None,
180
+ "timed_out": True,
181
+ }
182
+
183
+
184
+ def load_vectorstore_improved(sync_coordinator: Any) -> Dict[str, Any]:
185
+ """Supabase load step: return current coordinator health snapshot."""
186
+ if sync_coordinator is None:
187
+ return {}
188
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  try:
190
+ state = sync_coordinator.get_health_snapshot()
191
+ return state if isinstance(state, dict) else {}
192
+ except Exception:
193
+ logger.exception("Khong the lay sync state tu coordinator")
194
+ return {}
 
 
 
 
main.py CHANGED
@@ -29,9 +29,9 @@ from core.config import (
29
  from core.document_db import init_document_db
30
  from core.supabase_sync_service import SupabaseStorageSyncService, SupabaseSyncCoordinator
31
  from core.collection_router_retriever import CollectionRouterRetriever
 
32
  from core.models import embeddings
33
  from core.qa_pipeline import ask_ai_improved, ask_ai_stream_delta
34
- from api.admin_documents_router import router as admin_documents_router
35
  from api.admin_sync_router import router as admin_sync_router
36
 
37
  # Hàm log lỗi an toàn
@@ -170,42 +170,48 @@ async def lifespan(app: FastAPI):
170
  app.state.supabase_sync_service = sync_service
171
  app.state.supabase_sync_coordinator = sync_coordinator
172
 
173
- startup_sync_task = asyncio.create_task(
174
- sync_coordinator.run_sync(
175
- trigger="startup:initial_sync",
176
- queue_if_locked=False,
177
- )
178
  )
 
 
179
  app.state.supabase_startup_sync_task = startup_sync_task
 
 
180
 
181
- if SUPABASE_STARTUP_SYNC_WAIT_SECONDS > 0:
182
- try:
183
- initial_sync = await asyncio.wait_for(
184
- asyncio.shield(startup_sync_task),
185
- timeout=SUPABASE_STARTUP_SYNC_WAIT_SECONDS,
186
- )
187
- if initial_sync.get("status") == "failed":
188
- logger.warning(
189
- "Supabase initial sync failed at startup. service will continue and retry in scheduler. error=%s",
190
- initial_sync.get("error"),
191
- )
192
- else:
193
- summary = initial_sync.get("result") if isinstance(initial_sync.get("result"), dict) else {}
194
- logger.info(
195
- "Supabase initial sync completed. added=%s updated=%s deleted=%s failed=%s total_objects=%s",
196
- summary.get("added", 0),
197
- summary.get("updated", 0),
198
- summary.get("deleted", 0),
199
- summary.get("failed", 0),
200
- summary.get("total_objects", 0),
201
- )
202
- except asyncio.TimeoutError:
203
  logger.warning(
204
  "Supabase initial sync is still running after %ss. API startup continues and sync will finish in background.",
205
  SUPABASE_STARTUP_SYNC_WAIT_SECONDS,
206
  )
 
 
 
 
 
 
 
207
  else:
208
- logger.info("Supabase initial sync chạy nền, không chặn startup (SUPABASE_STARTUP_SYNC_WAIT_SECONDS=0).")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  sync_stop_event = asyncio.Event()
211
  sync_task = asyncio.create_task(
@@ -274,7 +280,6 @@ def get_runtime_components(request: Request):
274
 
275
  #Cấu hình FastAPI với middleware CORS và lifespan để quản lý trạng thái hệ thống
276
  app = FastAPI(lifespan=lifespan, title= "RAG API SERVER")
277
- app.include_router(admin_documents_router)
278
  app.include_router(admin_sync_router)
279
 
280
  #Cho phép truy cập từ mọi nguồn
 
29
  from core.document_db import init_document_db
30
  from core.supabase_sync_service import SupabaseStorageSyncService, SupabaseSyncCoordinator
31
  from core.collection_router_retriever import CollectionRouterRetriever
32
+ from core.vectorstore import build_vectorstore_improved, load_vectorstore_improved
33
  from core.models import embeddings
34
  from core.qa_pipeline import ask_ai_improved, ask_ai_stream_delta
 
35
  from api.admin_sync_router import router as admin_sync_router
36
 
37
  # Hàm log lỗi an toàn
 
170
  app.state.supabase_sync_service = sync_service
171
  app.state.supabase_sync_coordinator = sync_coordinator
172
 
173
+ build_result = await build_vectorstore_improved(
174
+ sync_coordinator=sync_coordinator,
175
+ startup_wait_seconds=SUPABASE_STARTUP_SYNC_WAIT_SECONDS,
 
 
176
  )
177
+
178
+ startup_sync_task = build_result.get("task")
179
  app.state.supabase_startup_sync_task = startup_sync_task
180
+ initial_sync = build_result.get("initial_sync")
181
+ timed_out = bool(build_result.get("timed_out"))
182
 
183
+ if timed_out:
184
+ if SUPABASE_STARTUP_SYNC_WAIT_SECONDS > 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  logger.warning(
186
  "Supabase initial sync is still running after %ss. API startup continues and sync will finish in background.",
187
  SUPABASE_STARTUP_SYNC_WAIT_SECONDS,
188
  )
189
+ else:
190
+ logger.info("Supabase initial sync chạy nền, không chặn startup (SUPABASE_STARTUP_SYNC_WAIT_SECONDS=0).")
191
+ elif isinstance(initial_sync, dict) and initial_sync.get("status") == "failed":
192
+ logger.warning(
193
+ "Supabase initial sync failed at startup. service will continue and retry in scheduler. error=%s",
194
+ initial_sync.get("error"),
195
+ )
196
  else:
197
+ summary = initial_sync.get("result") if isinstance(initial_sync, dict) and isinstance(initial_sync.get("result"), dict) else {}
198
+ logger.info(
199
+ "Supabase initial sync completed. added=%s updated=%s deleted=%s failed=%s total_objects=%s",
200
+ summary.get("added", 0),
201
+ summary.get("updated", 0),
202
+ summary.get("deleted", 0),
203
+ summary.get("failed", 0),
204
+ summary.get("total_objects", 0),
205
+ )
206
+
207
+ sync_state = load_vectorstore_improved(sync_coordinator)
208
+ if sync_state:
209
+ logger.info(
210
+ "Supabase sync state loaded. running=%s queued_events=%s last_sync_at=%s",
211
+ sync_state.get("running"),
212
+ sync_state.get("queued_events"),
213
+ sync_state.get("last_sync_at"),
214
+ )
215
 
216
  sync_stop_event = asyncio.Event()
217
  sync_task = asyncio.create_task(
 
280
 
281
  #Cấu hình FastAPI với middleware CORS và lifespan để quản lý trạng thái hệ thống
282
  app = FastAPI(lifespan=lifespan, title= "RAG API SERVER")
 
283
  app.include_router(admin_sync_router)
284
 
285
  #Cho phép truy cập từ mọi nguồn