Phase 8 Complete: Sync Audit Log + Frontend Integration
- Thêm audit/sync_audit.py: Ghi lịch sử sync vào audit/sync_log.json
- Thêm API endpoints: /sync/history, /sync/history/{run_id}
- Frontend: Nút 'Lịch sử đồng bộ' + panel expand chi tiết từng file
- Sửa frontend served từ backend (http://localhost:8000)
- Cập nhật DEPLOYMENT_GUIDE với hướng dẫn chạy localhost
- Cập nhật ARCHITECTURE_MAP: Phase 8 hoàn thành
This commit is contained in:
53
api/main.py
53
api/main.py
@@ -5,7 +5,8 @@ import secrets
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, status
|
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, status
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse, FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel, Field, validator
|
from pydantic import BaseModel, Field, validator
|
||||||
import uvicorn
|
import uvicorn
|
||||||
@@ -24,6 +25,7 @@ from extraction.ocr_service import OCRService
|
|||||||
from extraction.text_extractor import TextExtractor
|
from extraction.text_extractor import TextExtractor
|
||||||
from chunking.markdown_chunker import MarkdownChunker
|
from chunking.markdown_chunker import MarkdownChunker
|
||||||
from indexing.vector_store import VectorStore
|
from indexing.vector_store import VectorStore
|
||||||
|
from audit import sync_audit
|
||||||
|
|
||||||
# --- Cấu hình Logging chuyên nghiệp ---
|
# --- Cấu hình Logging chuyên nghiệp ---
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -50,6 +52,15 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Serve frontend static files
|
||||||
|
FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
|
||||||
|
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||||
|
|
||||||
|
@app.get("/", tags=["Frontend"])
|
||||||
|
async def serve_frontend():
|
||||||
|
"""Serve frontend index.html."""
|
||||||
|
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
|
||||||
|
|
||||||
# --- Singleton Engine Instance ---
|
# --- Singleton Engine Instance ---
|
||||||
rag_engine = None
|
rag_engine = None
|
||||||
sync_status = {"running": False, "last_run": None, "processed": 0, "skipped": 0, "errors": []}
|
sync_status = {"running": False, "last_run": None, "processed": 0, "skipped": 0, "errors": []}
|
||||||
@@ -270,6 +281,8 @@ def run_sync_background():
|
|||||||
global sync_status
|
global sync_status
|
||||||
sync_status = {"running": True, "last_run": None, "processed": 0, "skipped": 0, "errors": []}
|
sync_status = {"running": True, "last_run": None, "processed": 0, "skipped": 0, "errors": []}
|
||||||
|
|
||||||
|
run_id = sync_audit.start_run()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
provider = SharePointProvider()
|
provider = SharePointProvider()
|
||||||
dce = DocumentClassificationEngine(provider=provider)
|
dce = DocumentClassificationEngine(provider=provider)
|
||||||
@@ -304,16 +317,22 @@ def run_sync_background():
|
|||||||
|
|
||||||
if classification.processing_policy in (ProcessingPolicy.UNSUPPORTED, ProcessingPolicy.METADATA_ONLY, ProcessingPolicy.REQUIRES_REVIEW):
|
if classification.processing_policy in (ProcessingPolicy.UNSUPPORTED, ProcessingPolicy.METADATA_ONLY, ProcessingPolicy.REQUIRES_REVIEW):
|
||||||
sync_status["skipped"] += 1
|
sync_status["skipped"] += 1
|
||||||
|
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||||
|
classification.doc_type.value, 0, "skipped", classification.reason)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_bytes = provider.download_file(item)
|
file_bytes = provider.download_file(item)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sync_status["errors"].append(f"{name}: download failed")
|
sync_status["errors"].append(f"{name}: download failed")
|
||||||
|
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||||
|
classification.doc_type.value, 0, "error", str(e))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not file_bytes:
|
if not file_bytes:
|
||||||
sync_status["errors"].append(f"{name}: empty file")
|
sync_status["errors"].append(f"{name}: empty file")
|
||||||
|
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||||
|
classification.doc_type.value, 0, "error", "empty file")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
pages = []
|
pages = []
|
||||||
@@ -336,6 +355,8 @@ def run_sync_background():
|
|||||||
|
|
||||||
if not pages:
|
if not pages:
|
||||||
sync_status["skipped"] += 1
|
sync_status["skipped"] += 1
|
||||||
|
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||||
|
classification.doc_type.value, 0, "skipped", "no content extracted")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
@@ -352,16 +373,22 @@ def run_sync_background():
|
|||||||
vector_db.delete_by_file_id(item_id)
|
vector_db.delete_by_file_id(item_id)
|
||||||
vector_db.embed_and_index(chunks)
|
vector_db.embed_and_index(chunks)
|
||||||
sync_status["processed"] += 1
|
sync_status["processed"] += 1
|
||||||
|
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||||
|
classification.doc_type.value, len(chunks), "indexed")
|
||||||
logger.info(f"Sync: Indexed {name} → {len(chunks)} chunks")
|
logger.info(f"Sync: Indexed {name} → {len(chunks)} chunks")
|
||||||
else:
|
else:
|
||||||
sync_status["skipped"] += 1
|
sync_status["skipped"] += 1
|
||||||
|
sync_audit.log_file(run_id, name, item_id, classification.processing_policy.value,
|
||||||
|
classification.doc_type.value, 0, "skipped", "no chunks generated")
|
||||||
|
|
||||||
sync_status["last_run"] = "completed"
|
sync_status["last_run"] = "completed"
|
||||||
|
sync_audit.finish_run(run_id, "completed")
|
||||||
logger.info(f"Sync completed: {sync_status['processed']} processed, {sync_status['skipped']} skipped")
|
logger.info(f"Sync completed: {sync_status['processed']} processed, {sync_status['skipped']} skipped")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sync_status["last_run"] = "failed"
|
sync_status["last_run"] = "failed"
|
||||||
sync_status["errors"].append(str(e))
|
sync_status["errors"].append(str(e))
|
||||||
|
sync_audit.finish_run(run_id, "failed")
|
||||||
logger.error(f"Sync failed: {e}")
|
logger.error(f"Sync failed: {e}")
|
||||||
finally:
|
finally:
|
||||||
sync_status["running"] = False
|
sync_status["running"] = False
|
||||||
@@ -382,8 +409,30 @@ async def sync_endpoint(background_tasks: BackgroundTasks):
|
|||||||
|
|
||||||
@app.get("/sync/status", tags=["Ingestion"])
|
@app.get("/sync/status", tags=["Ingestion"])
|
||||||
async def sync_status_endpoint():
|
async def sync_status_endpoint():
|
||||||
"""Kiểm tra trạng thái đồng bộ."""
|
"""Kiểm tra trạng thái đồng bộ hiện tại."""
|
||||||
return sync_status
|
return sync_status
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sync/history", tags=["Ingestion"])
|
||||||
|
async def sync_history_endpoint(limit: int = 10):
|
||||||
|
"""
|
||||||
|
Lấy lịch sử đồng bộ (audit log).
|
||||||
|
Args:
|
||||||
|
limit: Số lần sync gần nhất (mặc định 10)
|
||||||
|
"""
|
||||||
|
return {"runs": sync_audit.get_history(limit)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sync/history/{run_id}", tags=["Ingestion"])
|
||||||
|
async def sync_run_detail_endpoint(run_id: str):
|
||||||
|
"""
|
||||||
|
Lấy chi tiết của 1 lần đồng bộ.
|
||||||
|
"""
|
||||||
|
run = sync_audit.get_run_detail(run_id)
|
||||||
|
if not run:
|
||||||
|
raise HTTPException(status_code=404, detail="Run not found")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
|
|||||||
144
audit/sync_audit.py
Normal file
144
audit/sync_audit.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("SyncAudit")
|
||||||
|
|
||||||
|
AUDIT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
AUDIT_FILE = os.path.join(AUDIT_DIR, "sync_log.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_log() -> Dict:
|
||||||
|
"""Load audit log từ file."""
|
||||||
|
if os.path.exists(AUDIT_FILE):
|
||||||
|
try:
|
||||||
|
with open(AUDIT_FILE, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"runs": []}
|
||||||
|
return {"runs": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_log(data: Dict):
|
||||||
|
"""Lưu audit log ra file."""
|
||||||
|
with open(AUDIT_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def start_run() -> str:
|
||||||
|
"""
|
||||||
|
Bắt đầu một sync run mới.
|
||||||
|
Returns: run_id
|
||||||
|
"""
|
||||||
|
run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
data = _load_log()
|
||||||
|
|
||||||
|
run = {
|
||||||
|
"run_id": run_id,
|
||||||
|
"started_at": datetime.now().isoformat(),
|
||||||
|
"finished_at": None,
|
||||||
|
"status": "running",
|
||||||
|
"summary": {"processed": 0, "skipped": 0, "errors": 0},
|
||||||
|
"files": [],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
data["runs"].insert(0, run) # Mới nhất lên đầu
|
||||||
|
|
||||||
|
# Giới hạn 50 runs gần nhất
|
||||||
|
if len(data["runs"]) > 50:
|
||||||
|
data["runs"] = data["runs"][:50]
|
||||||
|
|
||||||
|
_save_log(data)
|
||||||
|
logger.info(f"Audit: Started run {run_id}")
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
def log_file(run_id: str, name: str, item_id: str, policy: str,
|
||||||
|
doc_type: str, chunks: int, status: str, detail: str = ""):
|
||||||
|
"""
|
||||||
|
Ghi log cho 1 file đã xử lý.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run_id: ID của sync run
|
||||||
|
name: Tên file
|
||||||
|
item_id: SharePoint item ID
|
||||||
|
policy: Processing policy (requires_ocr, skip_ocr, metadata_only, unsupported)
|
||||||
|
doc_type: Document type (textual_document, spreadsheet, drawing, binary)
|
||||||
|
chunks: Số chunks đã tạo
|
||||||
|
status: indexed, skipped, error
|
||||||
|
detail: Ghi chú thêm
|
||||||
|
"""
|
||||||
|
data = _load_log()
|
||||||
|
|
||||||
|
for run in data["runs"]:
|
||||||
|
if run["run_id"] == run_id:
|
||||||
|
file_entry = {
|
||||||
|
"name": name,
|
||||||
|
"item_id": item_id,
|
||||||
|
"doc_type": doc_type,
|
||||||
|
"policy": policy,
|
||||||
|
"chunks": chunks,
|
||||||
|
"status": status,
|
||||||
|
"detail": detail,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
run["files"].append(file_entry)
|
||||||
|
|
||||||
|
# Cập nhật summary
|
||||||
|
if status == "indexed":
|
||||||
|
run["summary"]["processed"] += 1
|
||||||
|
elif status == "skipped":
|
||||||
|
run["summary"]["skipped"] += 1
|
||||||
|
elif status == "error":
|
||||||
|
run["summary"]["errors"] += 1
|
||||||
|
run["errors"].append(f"{name}: {detail}")
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
_save_log(data)
|
||||||
|
|
||||||
|
|
||||||
|
def finish_run(run_id: str, status: str = "completed"):
|
||||||
|
"""
|
||||||
|
Đánh dấu sync run hoàn thành.
|
||||||
|
"""
|
||||||
|
data = _load_log()
|
||||||
|
|
||||||
|
for run in data["runs"]:
|
||||||
|
if run["run_id"] == run_id:
|
||||||
|
run["finished_at"] = datetime.now().isoformat()
|
||||||
|
run["status"] = status
|
||||||
|
break
|
||||||
|
|
||||||
|
_save_log(data)
|
||||||
|
logger.info(f"Audit: Finished run {run_id} ({status})")
|
||||||
|
|
||||||
|
|
||||||
|
def get_history(limit: int = 10) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Lấy lịch sử sync runs.
|
||||||
|
"""
|
||||||
|
data = _load_log()
|
||||||
|
return data["runs"][:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def get_run_detail(run_id: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Lấy chi tiết của 1 sync run.
|
||||||
|
"""
|
||||||
|
data = _load_log()
|
||||||
|
for run in data["runs"]:
|
||||||
|
if run["run_id"] == run_id:
|
||||||
|
return run
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_run() -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Lấy sync run gần nhất.
|
||||||
|
"""
|
||||||
|
data = _load_log()
|
||||||
|
return data["runs"][0] if data["runs"] else None
|
||||||
128
audit/sync_log.json
Normal file
128
audit/sync_log.json
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"run_id": "20260511_035430",
|
||||||
|
"started_at": "2026-05-11T03:54:30.817636",
|
||||||
|
"finished_at": "2026-05-11T03:55:11.377114",
|
||||||
|
"status": "completed",
|
||||||
|
"summary": {
|
||||||
|
"processed": 8,
|
||||||
|
"skipped": 3,
|
||||||
|
"errors": 0
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "Quy-trinh-nghiem-thu-thiet-bi-v1.pdf",
|
||||||
|
"item_id": "01BP532D2O74Z6FYQPOVBKE5DBMYDWCWCK",
|
||||||
|
"doc_type": "textual_document",
|
||||||
|
"policy": "skip_ocr",
|
||||||
|
"chunks": 1,
|
||||||
|
"status": "indexed",
|
||||||
|
"detail": "",
|
||||||
|
"timestamp": "2026-05-11T03:54:47.793432"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bien-ban-ban-giao-scan.pdf",
|
||||||
|
"item_id": "01BP532D7QZ5QES5H675D2Q62S3YNCN7MV",
|
||||||
|
"doc_type": "textual_document",
|
||||||
|
"policy": "requires_ocr",
|
||||||
|
"chunks": 1,
|
||||||
|
"status": "indexed",
|
||||||
|
"detail": "",
|
||||||
|
"timestamp": "2026-05-11T03:54:54.449272"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "So-do-mat-bang-kho-A1.pdf",
|
||||||
|
"item_id": "01BP532D5ETRRCEFMAXVF3T5UOAN7ZMWSW",
|
||||||
|
"doc_type": "textual_document",
|
||||||
|
"policy": "skip_ocr",
|
||||||
|
"chunks": 1,
|
||||||
|
"status": "indexed",
|
||||||
|
"detail": "",
|
||||||
|
"timestamp": "2026-05-11T03:54:57.820068"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Quy-dinh-luu-tru-ho-so.docx",
|
||||||
|
"item_id": "01BP532D7R5UFAJ3N3FZC2LUGZ4EWKXVSV",
|
||||||
|
"doc_type": "textual_document",
|
||||||
|
"policy": "skip_ocr",
|
||||||
|
"chunks": 1,
|
||||||
|
"status": "indexed",
|
||||||
|
"detail": "",
|
||||||
|
"timestamp": "2026-05-11T03:55:00.316683"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mo-ta-nghiep-vu.xlsx",
|
||||||
|
"item_id": "01BP532D2GHD2SMYQZZRBJ5YBYXH32YLCC",
|
||||||
|
"doc_type": "spreadsheet",
|
||||||
|
"policy": "skip_ocr",
|
||||||
|
"chunks": 1,
|
||||||
|
"status": "indexed",
|
||||||
|
"detail": "",
|
||||||
|
"timestamp": "2026-05-11T03:55:02.318406"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sample-layout.dxf",
|
||||||
|
"item_id": "01BP532D2ICBWDIKS4ARA2QXJLUYYPARDV",
|
||||||
|
"doc_type": "drawing",
|
||||||
|
"policy": "metadata_only",
|
||||||
|
"chunks": 0,
|
||||||
|
"status": "skipped",
|
||||||
|
"detail": "Native CAD drawing format",
|
||||||
|
"timestamp": "2026-05-11T03:55:03.301004"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unsupported-binary.bin",
|
||||||
|
"item_id": "01BP532DZ77VD5TK6SVRC3EBEF3PTWPQ7J",
|
||||||
|
"doc_type": "binary",
|
||||||
|
"policy": "unsupported",
|
||||||
|
"chunks": 0,
|
||||||
|
"status": "skipped",
|
||||||
|
"detail": "Unsupported or binary extension: .bin",
|
||||||
|
"timestamp": "2026-05-11T03:55:04.189531"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mystery-file.xyzbin",
|
||||||
|
"item_id": "01BP532D7RTGNA3Q2TRRAIOS7LPEIBRAVT",
|
||||||
|
"doc_type": "binary",
|
||||||
|
"policy": "unsupported",
|
||||||
|
"chunks": 0,
|
||||||
|
"status": "skipped",
|
||||||
|
"detail": "Unsupported or binary extension: .xyzbin",
|
||||||
|
"timestamp": "2026-05-11T03:55:05.013405"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "README.txt",
|
||||||
|
"item_id": "01BP532D6PKB3WWG4X5VGYBMG7IFYZTG6M",
|
||||||
|
"doc_type": "textual_document",
|
||||||
|
"policy": "skip_ocr",
|
||||||
|
"chunks": 1,
|
||||||
|
"status": "indexed",
|
||||||
|
"detail": "",
|
||||||
|
"timestamp": "2026-05-11T03:55:06.986536"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bien-ban-noi-bo-restricted.docx",
|
||||||
|
"item_id": "01BP532D65XW2KEXQQMRF3JVLAQZOKIW2V",
|
||||||
|
"doc_type": "textual_document",
|
||||||
|
"policy": "skip_ocr",
|
||||||
|
"chunks": 1,
|
||||||
|
"status": "indexed",
|
||||||
|
"detail": "",
|
||||||
|
"timestamp": "2026-05-11T03:55:09.096708"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bang-ngan-sach-du-an.xlsx",
|
||||||
|
"item_id": "01BP532D6KRPO27NSMUBC3FXJYN7TKDMVR",
|
||||||
|
"doc_type": "spreadsheet",
|
||||||
|
"policy": "skip_ocr",
|
||||||
|
"chunks": 1,
|
||||||
|
"status": "indexed",
|
||||||
|
"detail": "",
|
||||||
|
"timestamp": "2026-05-11T03:55:11.376444"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -38,8 +38,9 @@ SharePoint → Ingestion → DCE → [OCR/Extract/Skip] → Chunking → OpenSea
|
|||||||
- **LLM Factory:** `chat/llm_factory.py` → Hỗ trợ Gemini, Groq, Local (config trong `.env`).
|
- **LLM Factory:** `chat/llm_factory.py` → Hỗ trợ Gemini, Groq, Local (config trong `.env`).
|
||||||
|
|
||||||
### E. Tầng API & Frontend
|
### E. Tầng API & Frontend
|
||||||
- **Backend:** `api/main.py` → FastAPI tại port 8000. Endpoint: `/health`, `/auth/login` (SSO), `/auth/callback`, `/auth/login-email`, `/chat`, `/sync`, `/sync/status`.
|
- **Backend:** `api/main.py` → FastAPI tại port 8000. Endpoint: `/health`, `/auth/login` (SSO), `/auth/callback`, `/auth/login-email`, `/chat`, `/sync`, `/sync/status`, `/sync/history`, `/sync/history/{run_id}`.
|
||||||
- **Frontend:** `frontend/` → Glassmorphism UI với SSO login + email fallback + sync button. Gọi `http://localhost:8000`.
|
- **Frontend:** `frontend/` → Glassmorphism UI với SSO login + email fallback + sync button + sync history panel. Gọi `http://localhost:8000`.
|
||||||
|
- **Audit:** `audit/sync_audit.py` → Lưu lịch sử sync vào `audit/sync_log.json`.
|
||||||
|
|
||||||
### F. Tầng Cấu hình (Decoupled Configuration)
|
### F. Tầng Cấu hình (Decoupled Configuration)
|
||||||
- Toàn bộ thông số trong `.env`. Load qua `core/config.py`.
|
- Toàn bộ thông số trong `.env`. Load qua `core/config.py`.
|
||||||
@@ -83,6 +84,9 @@ SharePoint → Ingestion → DCE → [OCR/Extract/Skip] → Chunking → OpenSea
|
|||||||
│ └── local_llm.py
|
│ └── local_llm.py
|
||||||
├── 📁 api/
|
├── 📁 api/
|
||||||
│ └── main.py # 🚀 FastAPI Backend (port 8000)
|
│ └── main.py # 🚀 FastAPI Backend (port 8000)
|
||||||
|
├── 📁 audit/
|
||||||
|
│ ├── sync_audit.py # 📋 Sync audit logging (ghi lịch sử sync)
|
||||||
|
│ └── sync_log.json # 📄 Audit log data
|
||||||
├── 📁 frontend/
|
├── 📁 frontend/
|
||||||
│ ├── index.html # 🎨 Glassmorphism UI (Login + Chat + Sync)
|
│ ├── index.html # 🎨 Glassmorphism UI (Login + Chat + Sync)
|
||||||
│ ├── app.js # 💬 Chat, Auth, Sync logic
|
│ ├── app.js # 💬 Chat, Auth, Sync logic
|
||||||
@@ -175,12 +179,10 @@ SharePoint → Ingestion → DCE → [OCR/Extract/Skip] → Chunking → OpenSea
|
|||||||
- [x] Auth UI: Simple email login + SSO Azure AD + user context cho API calls
|
- [x] Auth UI: Simple email login + SSO Azure AD + user context cho API calls
|
||||||
- [x] DOCX Text Extraction: python-docx (paragraphs + tables)
|
- [x] DOCX Text Extraction: python-docx (paragraphs + tables)
|
||||||
- [x] XLSX Text Extraction: openpyxl (sheets + cells)
|
- [x] XLSX Text Extraction: openpyxl (sheets + cells)
|
||||||
|
- [x] Sync Audit: Lịch sử sync persist vào file + API + Frontend panel
|
||||||
|
|
||||||
### Chưa triển khai (Phase 9 - Production Ready)
|
### Chưa triển khai (Phase 9 - Production Ready)
|
||||||
|
|
||||||
#### Ưu tiên trung bình
|
|
||||||
- [ ] **Cấu hình Azure AD cho SSO:** Thêm Redirect URI `http://localhost:8000/auth/callback` và bật "ID tokens" trong App Registration.
|
|
||||||
|
|
||||||
#### Ưu tiên thấp
|
#### Ưu tiên thấp
|
||||||
- [ ] **Monitoring Dashboard:** Health metrics, ingestion status, OCR success rate.
|
- [ ] **Monitoring Dashboard:** Health metrics, ingestion status, OCR success rate.
|
||||||
- [ ] **Multi-tenant:** Hỗ trợ nhiều SharePoint site/tenant.
|
- [ ] **Multi-tenant:** Hỗ trợ nhiều SharePoint site/tenant.
|
||||||
|
|||||||
@@ -4,6 +4,42 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0. Chạy hệ thống trên Localhost
|
||||||
|
|
||||||
|
### Yêu cầu
|
||||||
|
- Python 3.10+ với venv đã cài dependencies
|
||||||
|
- Docker (cho OpenSearch)
|
||||||
|
- Azure AD App Registration đã cấu hình
|
||||||
|
|
||||||
|
### Các bước khởi động
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Khởi động OpenSearch
|
||||||
|
docker-compose up -d opensearch
|
||||||
|
|
||||||
|
# 2. Khởi động Backend (serves cả API + Frontend)
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 api/main.py
|
||||||
|
|
||||||
|
# 3. Mở trình duyệt
|
||||||
|
# http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Truy cập
|
||||||
|
|
||||||
|
| URL | Mô tả |
|
||||||
|
|-----|-------|
|
||||||
|
| `http://localhost:8000` | Frontend (Login + Chat) |
|
||||||
|
| `http://localhost:8000/docs` | Swagger API Documentation |
|
||||||
|
| `http://localhost:8000/health` | Health check |
|
||||||
|
| `http://localhost:8000/auth/login` | SSO Azure AD redirect |
|
||||||
|
|
||||||
|
### Đăng nhập
|
||||||
|
- **SSO:** Bấm "Đăng nhập Microsoft SSO" → đăng nhập bằng tài khoản Microsoft 365
|
||||||
|
- **Email:** Nhập email bất kỳ (fallback, không cần password)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. Cấu hình Azure AD App Registration (MANUAL)
|
## 1. Cấu hình Azure AD App Registration (MANUAL)
|
||||||
|
|
||||||
### 1.1 Tạo App Registration (nếu chưa có)
|
### 1.1 Tạo App Registration (nếu chưa có)
|
||||||
@@ -36,13 +72,13 @@
|
|||||||
4. Bấm **Add permissions**
|
4. Bấm **Add permissions**
|
||||||
5. **Quan trọng:** Bấm **Grant admin consent for [tenant]** → Confirm
|
5. **Quan trọng:** Bấm **Grant admin consent for [tenant]** → Confirm
|
||||||
|
|
||||||
### 1.4 Cấu hình Redirect URI cho SSO (khi cần login)
|
### 1.4 Cấu hình Redirect URI cho SSO ✅ ĐÃ CẤU HÌNH
|
||||||
|
|
||||||
1. App Registration → **Authentication** → **Add a platform** → **Web**
|
1. App Registration → **Authentication** → **Add a platform** → **Web**
|
||||||
2. Nhập Redirect URI:
|
2. Nhập Redirect URI:
|
||||||
- **PoC (localhost):** `http://localhost:8000/auth/callback`
|
- **PoC (localhost):** `http://localhost:8000/auth/callback` ✅
|
||||||
- **Production:** `https://your-domain.com/auth/callback`
|
- **Production:** `https://your-domain.com/auth/callback` (thêm khi có domain)
|
||||||
3. Tích chọn: ✅ **ID tokens** (implicit grant)
|
3. Tích chọn: ✅ **ID tokens** (implicit grant) ✅
|
||||||
4. Bấm **Save**
|
4. Bấm **Save**
|
||||||
|
|
||||||
### 1.5 Kiểm tra Token Claims
|
### 1.5 Kiểm tra Token Claims
|
||||||
@@ -350,9 +386,11 @@ curl -X POST http://localhost:8000/chat \
|
|||||||
|
|
||||||
## 7. Checklist trước khi Production
|
## 7. Checklist trước khi Production
|
||||||
|
|
||||||
- [ ] Azure AD App Registration đã cấu hình đúng permissions
|
- [x] Azure AD App Registration đã cấu hình đúng permissions
|
||||||
- [ ] Client Secret còn hạn sử dụng
|
- [x] Client Secret còn hạn sử dụng
|
||||||
- [ ] Redirect URI đã thêm cho production domain
|
- [x] Redirect URI đã thêm cho localhost (`http://localhost:8000/auth/callback`)
|
||||||
|
- [x] ID tokens đã bật (implicit grant)
|
||||||
|
- [ ] Redirect URI cho production domain (khi có domain thật)
|
||||||
- [ ] OpenSearch đã đổi password mặc định
|
- [ ] OpenSearch đã đổi password mặc định
|
||||||
- [ ] SSL certificate đã cài đặt
|
- [ ] SSL certificate đã cài đặt
|
||||||
- [ ] CORS đã giới hạn origins
|
- [ ] CORS đã giới hạn origins
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// API Base URL
|
// API Base URL (relative khi served từ backend)
|
||||||
const API_BASE = 'http://localhost:8000';
|
const API_BASE = window.location.origin;
|
||||||
|
|
||||||
// DOM Elements
|
// DOM Elements
|
||||||
const loginScreen = document.getElementById('login-screen');
|
const loginScreen = document.getElementById('login-screen');
|
||||||
@@ -19,6 +19,10 @@ const logoutBtn = document.getElementById('logout-btn');
|
|||||||
const syncBtn = document.getElementById('sync-btn');
|
const syncBtn = document.getElementById('sync-btn');
|
||||||
const syncStatus = document.getElementById('sync-status');
|
const syncStatus = document.getElementById('sync-status');
|
||||||
const ssoBtn = document.getElementById('sso-btn');
|
const ssoBtn = document.getElementById('sso-btn');
|
||||||
|
const historyBtn = document.getElementById('history-btn');
|
||||||
|
const historyPanel = document.getElementById('history-panel');
|
||||||
|
const historyList = document.getElementById('history-list');
|
||||||
|
const closeHistory = document.getElementById('close-history');
|
||||||
|
|
||||||
let chatHistory = [];
|
let chatHistory = [];
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
@@ -287,5 +291,71 @@ async function pollSyncStatus() {
|
|||||||
|
|
||||||
syncBtn.onclick = triggerSync;
|
syncBtn.onclick = triggerSync;
|
||||||
|
|
||||||
|
// ====== SYNC HISTORY ======
|
||||||
|
|
||||||
|
async function loadSyncHistory() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/sync/history?limit=20`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
historyList.innerHTML = '';
|
||||||
|
|
||||||
|
if (!data.runs || data.runs.length === 0) {
|
||||||
|
historyList.innerHTML = '<p style="color: var(--text-muted); font-size: 13px; text-align: center; padding: 20px;">Chưa có lần đồng bộ nào.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.runs.forEach(run => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'history-item';
|
||||||
|
|
||||||
|
const time = new Date(run.started_at).toLocaleString('vi-VN');
|
||||||
|
const statusClass = run.status;
|
||||||
|
const statusText = run.status === 'completed' ? 'Hoàn thành' :
|
||||||
|
run.status === 'failed' ? 'Thất bại' : 'Đang chạy';
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="history-header">
|
||||||
|
<span class="history-time">${time}</span>
|
||||||
|
<span class="history-status ${statusClass}">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-summary">
|
||||||
|
<span><i class="fas fa-check-circle" style="color: #10b981"></i> ${run.summary.processed} nạp</span>
|
||||||
|
<span><i class="fas fa-forward" style="color: #eab308"></i> ${run.summary.skipped} bỏ</span>
|
||||||
|
<span><i class="fas fa-exclamation-circle" style="color: #ef4444"></i> ${run.summary.errors} lỗi</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-files">
|
||||||
|
${run.files.map(f => `
|
||||||
|
<div class="history-file">
|
||||||
|
<span class="history-file-name" title="${f.name}">${f.name}</span>
|
||||||
|
<span class="history-file-status ${f.status}">${f.chunks > 0 ? f.chunks + ' chunks' : f.status}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
${run.errors.length > 0 ? `
|
||||||
|
<div style="margin-top: 8px; font-size: 11px; color: #ef4444;">
|
||||||
|
${run.errors.map(e => `<div>⚠ ${e}</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
item.onclick = () => item.classList.toggle('expanded');
|
||||||
|
historyList.appendChild(item);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load history error:', error);
|
||||||
|
historyList.innerHTML = '<p style="color: #ef4444; font-size: 13px; text-align: center;">Lỗi tải lịch sử.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
historyBtn.onclick = () => {
|
||||||
|
historyPanel.classList.toggle('active');
|
||||||
|
if (historyPanel.classList.contains('active')) {
|
||||||
|
loadSyncHistory();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
closeHistory.onclick = () => historyPanel.classList.remove('active');
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
checkLogin();
|
checkLogin();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AI Knowledge Hub | Enterprise RAG</title>
|
<title>AI Knowledge Hub | Enterprise RAG</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
<nav class="side-nav">
|
<nav class="side-nav">
|
||||||
<div class="nav-item active"><i class="fas fa-comments"></i> <span>Hỏi đáp RAG</span></div>
|
<div class="nav-item active"><i class="fas fa-comments"></i> <span>Hỏi đáp RAG</span></div>
|
||||||
<button class="sync-btn" id="sync-btn"><i class="fas fa-sync-alt"></i> Đồng bộ SharePoint</button>
|
<button class="sync-btn" id="sync-btn"><i class="fas fa-sync-alt"></i> Đồng bộ SharePoint</button>
|
||||||
|
<button class="sync-btn" id="history-btn"><i class="fas fa-history"></i> Lịch sử đồng bộ</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sync-status" id="sync-status" style="display: none;">
|
<div class="sync-status" id="sync-status" style="display: none;">
|
||||||
@@ -112,8 +113,19 @@
|
|||||||
<!-- Nguồn sẽ được render ở đây -->
|
<!-- Nguồn sẽ được render ở đây -->
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Sync History Panel -->
|
||||||
|
<section class="source-panel" id="history-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3><i class="fas fa-history"></i> Lịch sử đồng bộ</h3>
|
||||||
|
<button id="close-history"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="history-list" id="history-list">
|
||||||
|
<!-- Lịch sử sẽ được render ở đây -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -344,6 +344,122 @@ textarea {
|
|||||||
.source-item h4 { font-size: 14px; margin-bottom: 5px; color: var(--primary); }
|
.source-item h4 { font-size: 14px; margin-bottom: 5px; color: var(--primary); }
|
||||||
.source-item p { font-size: 12px; color: var(--text-muted); line-height: 1.4; }
|
.source-item p { font-size: 12px; color: var(--text-muted); line-height: 1.4; }
|
||||||
|
|
||||||
|
/* History Panel */
|
||||||
|
.history-item {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .history-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .history-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .history-status {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .history-status.completed {
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .history-status.failed {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .history-status.running {
|
||||||
|
background: rgba(139, 92, 246, 0.2);
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .history-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .history-summary span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-files {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item.expanded .history-files {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-name {
|
||||||
|
color: var(--text-main);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-status {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-status.indexed {
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-status.skipped {
|
||||||
|
background: rgba(234, 179, 8, 0.2);
|
||||||
|
color: #eab308;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-file-status.error {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
/* Login Screen */
|
/* Login Screen */
|
||||||
.login-screen {
|
.login-screen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user