Spaces:
Runtime error
Runtime error
update process document
Browse files- README.md +14 -15
- api/admin_documents_router.py +0 -148
- core/config.py +0 -10
- core/document_ingest_service.py +66 -67
- core/vectorstore.py +104 -309
- main.py +35 -30
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.
|
| 48 |
-
5.
|
| 49 |
-
6.
|
| 50 |
7. Danh dau app san sang (endpoint `/healthz` se bao `ready=true`).
|
| 51 |
|
| 52 |
-
## 3) Luong ingest tai lieu (
|
| 53 |
|
| 54 |
-
Luong nay
|
| 55 |
|
| 56 |
-
1.
|
| 57 |
-
2.
|
| 58 |
-
3.
|
| 59 |
-
4.
|
| 60 |
-
5. Chunk van ban
|
| 61 |
6. Embedding chunks bang model trong `core/models.py`.
|
| 62 |
-
7.
|
| 63 |
-
8.
|
| 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`:
|
| 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
|
|
|
|
| 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
|
| 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 .
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 60 |
-
|
| 61 |
-
row_cells = [cell.text.strip() for cell in row.cells]
|
| 62 |
-
if any(row_cells):
|
| 63 |
-
paragraphs.append(" | ".join(row_cells))
|
| 64 |
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
raise ValueError("CHUNK_OVERLAP must be smaller than CHUNK_SIZE")
|
| 78 |
|
| 79 |
-
|
| 80 |
-
if not tokens:
|
| 81 |
-
return []
|
| 82 |
|
| 83 |
-
step = chunk_size - overlap
|
| 84 |
-
chunks: List[str] = []
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
-
return
|
| 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 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
| 210 |
|
| 211 |
-
|
| 212 |
|
| 213 |
extension_source = source_object_ref or document.stored_name or ingest_file_path
|
| 214 |
_, extension = os.path.splitext(extension_source)
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, (
|
|
|
|
|
|
|
| 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
|
| 4 |
-
|
| 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.
|
|
|
|
| 21 |
from docx.text.paragraph import Paragraph
|
| 22 |
-
import
|
|
|
|
|
|
|
| 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 |
-
|
| 151 |
-
header = [str(cell).replace(
|
| 152 |
separator = ["---"] * len(header)
|
| 153 |
-
markdown_lines = [
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
| 156 |
for row in data[1:]:
|
| 157 |
-
clean_row = [str(cell).replace(
|
| 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
|
| 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 |
-
|
|
|
|
| 175 |
if full_content.strip():
|
| 176 |
-
docs.append(
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 232 |
-
|
| 233 |
-
|
| 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
|
| 275 |
docs = read_pdf_with_tables(filepath)
|
| 276 |
-
elif
|
| 277 |
text = read_docx_with_tables(filepath)
|
| 278 |
if text:
|
| 279 |
docs = [LangChainDocument(page_content=text, metadata={"source": filepath})]
|
| 280 |
-
elif
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
| 283 |
if docs:
|
| 284 |
-
logger.info(
|
|
|
|
| 285 |
return docs
|
| 286 |
-
except Exception as
|
| 287 |
-
logger.error(
|
| 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 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
)
|
| 327 |
-
#Đẩy chunks lên cloud
|
| 328 |
-
db.add_documents(chunks)
|
| 329 |
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
try:
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 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 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
logger.
|
| 395 |
-
|
| 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 |
-
|
| 174 |
-
sync_coordinator
|
| 175 |
-
|
| 176 |
-
queue_if_locked=False,
|
| 177 |
-
)
|
| 178 |
)
|
|
|
|
|
|
|
| 179 |
app.state.supabase_startup_sync_task = startup_sync_task
|
|
|
|
|
|
|
| 180 |
|
| 181 |
-
if
|
| 182 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|